Compare commits

...

60 Commits

Author SHA1 Message Date
Kelly
937a1b5024 Fix CI: Code style issues identified by Laravel Pint
- Fixed code style issues in SettingsController, WashReportController
- Fixed PHPDoc alignment in AuditLog model
- Fixed PHPDoc tags and trim in PermissionService
- Fixed spacing and unary operators in seller routes

This fixes the failing Woodpecker CI pipeline for PR#45.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 11:14:00 -07:00
Kelly
11beea936b fix: resolve duplicate migration timestamp for vehicles table
- Renamed 2025_10_10_034707_create_vehicles_table.php to 2025_10_10_034708_create_vehicles_table.php
- Migration now runs sequentially after drivers table creation
- Resolves PostgreSQL duplicate table creation errors in CI tests
2025-11-11 02:34:54 -07:00
Kelly
f05a35b8b1 chore: trigger CI pipeline
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 00:14:45 -07:00
Kelly
ebeb340769 chore: trigger CI 2025-11-10 23:24:53 -07:00
Kelly
2f06a35501 chore: fix code style issues with Laravel Pint 2025-11-10 22:46:23 -07:00
Kelly
70b3be142b Add settings pages, analytics fixes, and brand management
- Add comprehensive settings pages (profile, security, brand kit, sales config)
- Add Brands menu item to seller sidebar
- Fix analytics products page Collection error
- Fix wash reports stage transition
- Restructure company information page layout with right sidebar
- Add Conversion model with stage data support
- Add audit logging system with jobs and migrations
- Add user profile fields and social media links
- Add permissions system documentation
- Fix route naming consistency across wash reports
- Update analytics controller to use product_id filtering
2025-11-10 01:01:21 -07:00
Kelly
1632f2301e Fix seller-account-dropdown to use current business from route
- Changed to use request()->route('business') to get current business from route parameter instead of primaryBusiness()
- Fixed Profile link to use seller.business.profile route with business slug
- Ensures all routes in dropdown use correct business context from the URL
2025-11-09 18:17:59 -07:00
Kelly
c7f74aba08 Add missing wash-reports routes
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 18:10:51 -07:00
Kelly
f0624dd194 Add missing batches routes
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 18:04:26 -07:00
Kelly
6edc6f2468 Add missing analytics.buyers route
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 17:59:13 -07:00
Kelly
c6ada94a56 Add missing analytics.sales route
- Added analytics.sales route to analytics route group (routes/seller.php:197)
- Route creates full name: seller.business.analytics.sales
- Fixes RouteNotFoundException when navigating to sales analytics

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 17:54:44 -07:00
Kelly
ec3f6338b5 Add missing analytics.marketing route
- Added analytics.marketing route to analytics route group (routes/seller.php:199)
- Route creates full name: seller.business.settings.analytics.marketing
- Fixes RouteNotFoundException when navigating to marketing analytics
2025-11-09 17:51:53 -07:00
Kelly
3fbd05dff7 Fix RouteNotFoundException errors in seller routes
- Fixed view switcher route name from 'seller.view.switch' to 'view.switch' to work with nested route group prefixes (routes/seller.php:274)
- Updated view-switcher component to use full route name 'seller.business.settings.view.switch' with business slug parameter (view-switcher.blade.php:53)
- Added missing analytics.products and analytics.products.show routes to analytics route group (routes/seller.php:195-199)

Resolves RouteNotFoundException for:
- seller.view.switch (now seller.business.settings.view.switch)
- seller.business.analytics.products

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 17:40:47 -07:00
Kelly
9720ca6574 Add missing invoice routes for plans-and-billing page
- Added invoice.view route for viewing invoices
- Added invoice.download route for downloading invoice PDFs
- These routes are called by plans-and-billing.blade.php

Fixes RouteNotFoundException for invoice actions.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 17:23:10 -07:00
Kelly
7904e5764c Apply settings enhancements from stash (cherry-picked)
Cherry-picked the following from stash@{2}:
- Business model: Added 39 settings fields for orders, invoices, notifications
- Brand model: Added HashID trait, storage path support, enhanced fields
- Component model: Added HashID trait and category relationship
- Config: MinIO filesystem configuration for production
- Blade views: Enhanced settings pages (brands, company-info, invoices, notifications, orders)
- .env.example: MinIO configuration documentation

KEPT current versions (have PermissionService integration):
- SettingsController.php (current version is more advanced)
- seller-sidebar.blade.php (already has latest navigation)
- routes/seller.php (already has all routes)

This avoids the "fix one thing, break another" cycle by selectively
merging only non-conflicting improvements while preserving recent
PermissionService work.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 17:18:47 -07:00
Kelly
812238945b Fix route naming for view switcher
- Changed route name from 'view.switch' to 'seller.view.switch'
- Matches the route call in view-switcher.blade.php component
- Resolves RouteNotFoundException for seller.view.switch

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 17:18:07 -07:00
Kelly
f685f1e248 WIP: Current navigation and settings work before stash apply 2025-11-09 17:08:46 -07:00
Kelly
aaddd75963 feat: restore comprehensive seller settings and features from stash
This commit restores extensive work on seller settings pages and features:

**Settings Pages Enhanced:**
- Users management with detailed permissions and role management
- Brands management with enhanced UI
- Company information with comprehensive business details
- Invoices settings with customization options
- Orders settings with prefix/numbering configuration
- Notifications settings with granular controls

**Models Updated:**
- Brand model with additional relationships and category support
- Product model with category relationships
- Business model enhancements

**Controllers Added:**
- ProductCategoryController for product category management
- ComponentCategoryController for component category management

**Migrations Added:**
- Product categories table
- Component categories table
- Category relationships for products and components
- Order settings for businesses
- Invoice settings for businesses
- Notification settings for businesses
- Physical suite address field
- Category hierarchy (parent_id)
- Performance indexes for orders

**Documentation:**
- Categories setup guide
- Hash Factory migration plan
- Products field mapping

**Routes:**
- Added buyer/seller category routes
- Enhanced settings routes

**Views:**
- Category CRUD views (index, create, edit)
- Enhanced product edit view
- Marketing template models added

**Conflict Resolution Strategy:**
- Kept current SettingsController and seller-sidebar (today's work)
- Applied stash versions for models, routes, and other settings pages
- Ensured no loss of recent subscription/billing work

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 15:50:32 -07:00
Kelly
7d62d3cd7e feat: restore WashReportController and routes for wash reports functionality 2025-11-09 15:39:10 -07:00
Kelly
9832d8e17c Restore subscription/billing work and settings improvements
- Applied stash@{0} with subscription and billing features
- Resolved merge conflicts in 3 files
- Added comprehensive plans-and-billing page with:
  - Current plan display with features
  - Payment methods management
  - Billing contacts
  - Invoice history with PDF downloads
  - Plan change modal (Standard/Business/Premium tiers)
- Enhanced SettingsController with plan change logic:
  - Upgrade with prorated billing
  - Downgrade scheduling for next billing cycle
  - Invoice generation
- Updated Business model with subscription relationship
- Enhanced settings index with improved profile/password forms
- Updated company information page
- Improved manage licenses page
- Added permission audit migrations
- Updated routes for billing features

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 15:35:30 -07:00
Kelly
89fbd336ba WIP: Save current work before applying stash with subscription/billing features
- Account dropdown with full settings menu
- Routes for integrations, webhooks, audit-logs
- Debug tools disabled for performance
2025-11-09 15:30:38 -07:00
Kelly
61d5c2d456 fix: remove marketing_templates foreign key constraint (table doesn't exist yet) 2025-11-09 12:38:29 -07:00
Kelly
fe3916e6b7 feat: add broadcast system with mass messaging
- Add 3 database tables (broadcasts, recipients, events)
- Add BroadcastService with sending logic
- Add 3 queue jobs (send, send message, scheduled)
- Add BroadcastController with full CRUD
- Add real-time progress tracking
- Add analytics dashboard
- Support email, SMS, push, multi-channel
- Add pause/resume/cancel functionality
- Add rate limiting support
- Add event tracking (opens, clicks, unsubscribes)
- Add beautiful Blade views with DaisyUI

Part 5 of Marketing System

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 12:24:09 -07:00
Kelly
3cefea3c7f feat: restore wash reports stage1 and stage2 views 2025-11-08 20:56:38 -07:00
Kelly
64d1a0dad2 chore: add CLAUDE.local.md to .gitignore
Allow developers to create local workflow notes without git tracking
2025-11-08 14:17:51 -07:00
Kelly
32e5e249fb Add module-based view switcher system with hierarchical settings
Implemented a comprehensive view switching system that enables module-based access control and navigation organization for Sales, Manufacturing, and Compliance modules.

**View Switcher System:**
- Created ViewSwitcherController to handle view switching logic
- Added view-switcher Blade component with sticky positioning
- Session-based view state management (current_view)
- Business-level module flags (has_manufacturing, has_compliance)

**Navigation Architecture:**
- Owners see full navigation across all modules regardless of view
- Department admins see only their module's navigation based on selected view
- Split sidebar navigation by view (Sales vs Manufacturing sections)
- Moved Fleet Management to Manufacturing department settings

**Settings Hierarchy:**
- Owner-only items (Notifications) at top
- Department separators (— Sales —, — Manufacturing —)
- Owners see all settings regardless of view
- Department admins see only their section based on current view

**Business Helper Updates:**
- Added hasModule() method for checking module enablement
- Added module flags to businesses table migration

**Finance Section:**
- Moved under Business section with subsection separator
- Includes Subscriptions & Billing and Reports

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 13:48:18 -07:00
Kelly
c7b7a23fce Organize documentation structure for AI assistant context
Added:
- CLAUDE_CONTEXT.md: Quick-reference guide for critical architectural decisions

Moved to docs/:
- Analytics implementation guides → docs/features/
- Architecture diagrams → docs/architecture/
- Product2 instructions → docs/features/

Kept in root for AI context:
- CLAUDE.md: Detailed rules and common mistakes
- QUICK-HANDOFF-CLAUDE-CODE.md: Analytics handoff guide
- README.md: Project overview
- CONTRIBUTING.md: Git workflow
- CHANGELOG.md: Version history

This structure ensures AI assistants can quickly find critical
context while keeping detailed documentation organized.
2025-11-08 09:24:24 -07:00
Kelly
3e986171a1 Add developer guides for Analytics and File Storage features 2025-11-08 09:20:39 -07:00
Kelly
d2eb6e11ea Add comprehensive documentation structure with Analytics and File Storage feature guides
Added:
- docs/README.md: Main documentation index with organized table of contents
- docs/features/ANALYTICS.md: Complete Analytics System feature guide
- docs/features/FILE_STORAGE.md: Complete File Storage System feature guide

This documentation provides:
- Clear feature overviews and use cases
- Database schema and indexes
- Controller implementations with security patterns
- Testing examples and best practices
- Future enhancement roadmap

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 09:18:44 -07:00
Kelly
0c40799ddc Merge feature/export-companies-products-images into develop 2025-11-08 09:03:06 -07:00
Kelly
5215d4a077 fix: update last remaining product->id to product->hashid in test
Fixed the deleting_primary_image test that was still using $product->id
instead of $product->hashid in the delete route.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 08:42:13 -07:00
Kelly
75ec53da63 fix: use product hashid instead of id in route parameters for tests
Updated ProductImageControllerTest and TenantScopingTest to use
$product->hashid instead of $product->id in route generation, matching
the custom route model binding that expects hashid values.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 08:37:54 -07:00
Kelly
4cf62d92e4 fix: add missing audits variable to ProductController edit methods
Added audits query to both edit() and edit1() methods to prevent
"Undefined variable $audits" error in product edit views.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 08:31:46 -07:00
Kelly
59d81b8f42 fix: remove analytics-tracking include from export-companies branch
The analytics-tracking partial doesn't exist on this branch as it's
part of the analytics feature branch. Removing the include to fix
CI test failures.

This will be re-added when the analytics branch is merged.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 08:26:42 -07:00
Kelly
7c0ec86823 fix: add missing analytics detail view files
Added missing view files for analytics detail pages:
- product-detail.blade.php
- buyer-detail.blade.php
- campaign-detail.blade.php

These views are referenced by the analytics controllers but were
not previously created, which could cause 500 errors when accessing
those routes.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 03:09:04 -07:00
Kelly
2c3d12a22c feat(analytics): add Overview menu item to analytics sidebar
Added an "Overview" link to the Analytics menu in the seller sidebar
that routes to the analytics dashboard index page. This provides users
with a clear entry point to view high-level analytics metrics.

The Overview link appears first in the analytics submenu and uses the
'analytics.overview' permission check, consistent with other analytics
menu items.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 02:59:04 -07:00
Kelly
d5ea59e83e fix: add BusinessHelper class to export-companies branch
The helpers.php file references BusinessHelper::current() and
BusinessHelper::hasPermission() but the BusinessHelper class file
wasn't committed to this branch, causing CI failures.

This adds the BusinessHelper class that provides business context
helpers used by the application.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 02:55:32 -07:00
Kelly
1371d2a59c fix: rename duplicate index names in click_tracking migration
- Change idx_business_element_time to idx_click_business_element_time
- Change idx_business_user_time to idx_click_business_user_time
- Prevents index name conflicts with other tables
2025-11-08 02:49:19 -07:00
Kelly
4dd2e3ae64 Fix code style issues with Laravel Pint
Applied Laravel Pint auto-fixes to resolve all 15 code style issues:
- Fixed unary operator spacing in analytics controllers
- Fixed concat_space in multiple files
- Fixed not_operator_with_successor_space
- Fixed method_chaining_indentation
- Fixed no_unused_imports
- Fixed nullable_type_declaration_for_default_null_value
- Fixed no_superfluous_phpdoc_tags
- Fixed phpdoc formatting
- Fixed braces_position
- Fixed class_attributes_separation
- Fixed ordered_imports
- Fixed single_quote

All changes are cosmetic code style improvements with no functional changes.
2025-11-08 02:43:08 -07:00
Kelly
a133477f9f fix: auto-generate hashid for new brands in boot() method
- Add generateHashid() method to Brand model
- Update boot() to auto-generate hashid when creating new brands
- Fixes test failures where Brand factory wasn't providing hashid
2025-11-08 02:37:05 -07:00
Kelly
1649909b73 style: fix Pint code style issues in analytics files
- Fix not_operator_with_successor_space in TrackingController.php
- Remove unused import in AnalyticsTracker.php
2025-11-08 02:32:47 -07:00
Kelly
66d55c4f0a style: fix all Pint code style issues across codebase 2025-11-08 02:23:40 -07:00
Kelly
5dd60cc71e style: fix Pint code style issues in routes/seller.php 2025-11-08 02:15:04 -07:00
Kelly
a5ac7d4217 refactor: rename analytics-tracker.blade.php to analytics.blade.php for clarity 2025-11-08 02:07:41 -07:00
Kelly
eb05a6bcf0 docs: update CLAUDE.md - analytics is now automatic on buyer/guest layouts 2025-11-08 02:06:03 -07:00
Kelly
572c207e39 feat: add automatic analytics tracking to buyer and guest layouts 2025-11-08 02:05:47 -07:00
Kelly
ef5f430e90 docs: add Analytics System usage guide to CLAUDE.md 2025-11-08 02:03:15 -07:00
Kelly
a99a0807d0 Add complete Analytics System with frontend tracking
- Remove global scopes from all analytics models
- Add SMS, Web Push, and Age Verify tracking migrations
- Create self-contained analytics-tracker.blade.php include
- Add TrackingController API endpoints for session/event tracking
- Update all analytics controllers with explicit business scoping
- Update AnalyticsTracker service with forBusiness() pattern

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 01:51:56 -07:00
Kelly
a0194bad9b Add comprehensive analytics implementation documentation
Created ANALYTICS_IMPLEMENTATION.md with complete documentation:

- Overview of all features implemented
- 10 analytics models with descriptions
- Database schema and indexing strategy
- Services, jobs, and real-time features
- 5 controllers and 4 view pages
- Navigation structure and permissions system
- Client-side tracking implementation
- Security test coverage
- Installation and usage instructions
- API endpoints reference
- Helper functions guide
- Git commit summary
- Next steps for future enhancements

Ready for deployment and testing.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-07 23:44:35 -07:00
Kelly
91451893fe Add comprehensive security tests for analytics system
Created AnalyticsSecurityTest with full multi-tenancy coverage:

Tests included:
✓ Analytics events scoped to business
✓ Product views scoped to business
✓ Buyer engagement scores scoped to business
✓ Permission checks (with/without analytics permissions)
✓ Cross-business access prevention
✓ forBusiness scope removes global scope correctly
✓ Unauthenticated access blocked
✓ Auto-set business_id on model creation

All tests verify:
- BusinessScope isolation
- Permission-based access control
- Data cannot leak between businesses
- Automatic business_id assignment

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-07 23:43:00 -07:00
Kelly
1786c2edb1 Add JavaScript tracking and Reverb real-time listeners
Created client-side analytics tracking:

analytics-tracker.js:
- AnalyticsTracker class for general event tracking
- ProductPageTracker for enhanced product engagement tracking
- Tracks: page views, clicks, scroll depth, time on page
- Product signals: image zoom, video play, spec download, cart/wishlist
- Uses sendBeacon for reliable page exit tracking
- Automatic click tracking via data-track-click attributes

reverb-analytics-listener.js:
- Real-time listener for high-intent buyer events
- Toast notifications for important signals
- Auto-navigation to buyer detail on notification click
- Notification badge updates
- Custom events for UI integration

Both integrate with existing Laravel backend via /api/analytics/track endpoint.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-07 23:42:00 -07:00
Kelly
9a81a22cc5 Add analytics permissions UI to user management
Added permission management system:
- Updated UserController with updatePermissions method
- Added route for updating user permissions
- Created permissions modal with checkboxes for all analytics permissions
- Added Permissions button to user cards (owner only)
- JavaScript for managing modal state and async permission updates
- Displays current permissions as badges on user cards

Permissions available:
- analytics.overview, products, marketing, sales, buyers, export

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-07 23:40:52 -07:00
Kelly
19bfa889b7 Add analytics views using Nexus/DaisyUI patterns
Created 4 main analytics views:
- dashboard.blade.php: Overview with key metrics, charts, and high-intent signals
- products.blade.php: Product performance, engagement breakdown, view trends
- marketing.blade.php: Email campaign analytics, device/client breakdown
- buyers.blade.php: Buyer intelligence, engagement scores, intent signals

All views use:
- DaisyUI components (cards, tables, badges)
- ApexCharts for data visualization
- Anime.js for counter animations
- Permission checks with hasBusinessPermission()
- Responsive grid layouts
- Period selectors for date filtering

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-07 23:35:42 -07:00
Kelly
c4bd508241 Update seller sidebar navigation for analytics
Restructured navigation to match requirements:
- Dashboard is now a single top-level item (not a collapse group)
- Analytics is a new parent section with subsections: Products, Marketing, Sales, Buyers
- Reports is a separate future section
- All analytics links use permission checks (hasBusinessPermission)
- Uses proper route names and active state indicators

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-07 23:31:57 -07:00
Kelly
b404a533b3 Add analytics models, services, jobs, events, and controllers
Implemented comprehensive analytics system with:
- 6 analytics models (EmailClick, ClickTracking, UserSession, IntentSignal, BuyerEngagementScore + existing 4)
- AnalyticsTracker service for tracking product views, clicks, emails, sessions
- Queue jobs (CalculateEngagementScore, ProcessAnalyticsEvent)
- Reverb event (HighIntentBuyerDetected) for real-time notifications
- 5 analytics controllers (Dashboard, Product, Marketing, Sales, Buyer Intelligence)
- Updated routes with comprehensive analytics routing

All models use BusinessScope for multi-tenancy consistency.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-07 23:30:58 -07:00
Kelly
65380b9649 feat(analytics): Add database foundation and helper functions
Phase 1 Complete - Database & Helpers:
- BusinessHelper class with permission checking via business_user.permissions
- Global helper functions (currentBusiness, currentBusinessId, hasBusinessPermission)
- 7 database migrations for analytics tracking
  * analytics_events - Raw event stream
  * product_views - Product engagement tracking
  * email_tracking_tables - Email campaigns, interactions, clicks
  * click_tracking - General click events
  * user_sessions_and_intent - Session tracking + intent signals + buyer engagement scores
  * analytics_permissions - Documentation of available permissions
  * analytics_jobs - Queue table for async processing

Phase 2 In Progress - Models with BusinessScope:
- AnalyticsEvent model
- ProductView model
- EmailCampaign model

All tables use business_id (bigInteger) with composite indexes for multi-tenancy.
All models apply BusinessScope for automatic business isolation.
Helper functions integrated with existing business_user pivot permissions.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-07 23:07:27 -07:00
Kelly
52facb768e chore: merge latest develop branch with conflict resolution 2025-11-07 00:13:39 -07:00
Kelly
e9230495b4 chore: merge develop - resolve .gitignore conflict 2025-11-07 00:10:25 -07:00
Kelly
06869cf05d feat: add brand management, hashid support, and various improvements 2025-11-07 00:08:20 -07:00
Kelly
555b988c4f chore: ignore nexus build artifacts and generated files
Add gitignore rules for nexus-html build artifacts:
- bootstrap/cache/
- storage/
- public/build/
- database/*.sqlite*

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 00:06:26 -07:00
343 changed files with 48855 additions and 3215 deletions

View File

@@ -19,7 +19,13 @@
"Bash(grep:*)",
"Bash(sed:*)",
"Bash(php artisan:*)",
"Bash(php check_blade.php:*)"
"Bash(php check_blade.php:*)",
"Bash(./vendor/bin/pint:*)",
"Bash(git worktree add:*)",
"Bash(if [ -d \"../hub-worktrees/feature/pr-8-analytics-dashboard\" ])",
"Bash(then echo \"EXISTS\")",
"Bash(else echo \"NOT_EXISTS\")",
"Bash(fi)"
],
"deny": [],
"ask": []

View File

@@ -34,6 +34,7 @@ SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=reverb
# FILESYSTEM_DISK options: local (development), public (local public), minio (production)
FILESYSTEM_DISK=local
QUEUE_CONNECTION=redis
@@ -77,25 +78,18 @@ MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
# AWS/MinIO S3 Storage Configuration
# Local development: Use FILESYSTEM_DISK=public (default)
# Production: Use FILESYSTEM_DISK=s3 with MinIO credentials below
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_ENDPOINT=
AWS_URL=
AWS_USE_PATH_STYLE_ENDPOINT=false
# Production MinIO Configuration (example):
# FILESYSTEM_DISK=s3
# AWS_ACCESS_KEY_ID=TrLoFnMOVQC2CqLm9711
# AWS_SECRET_ACCESS_KEY=4tfik06LitWz70L4VLIA45yXla4gi3zQI2IA3oSZ
# AWS_DEFAULT_REGION=us-east-1
# AWS_BUCKET=media
# AWS_ENDPOINT=https://cdn.cannabrands.app
# AWS_URL=https://cdn.cannabrands.app/media
# AWS_USE_PATH_STYLE_ENDPOINT=true
# MinIO Configuration (Production Object Storage)
# Set FILESYSTEM_DISK=minio in production
MINIO_ACCESS_KEY=
MINIO_SECRET_KEY=
MINIO_REGION=us-east-1
MINIO_BUCKET=cannabrands
MINIO_ENDPOINT=
VITE_APP_NAME="${APP_NAME}"

8
.gitignore vendored
View File

@@ -58,4 +58,12 @@ core.*
!resources/**/*.png
!resources/**/*.jpg
!resources/**/*.jpeg
# Nexus HTML build artifacts and generated files
nexus-html@*/bootstrap/cache/
nexus-html@*/storage/
nexus-html@*/public/build/
nexus-html@*/database/*.sqlite*
.claude/settings.local.json
CLAUDE.local.md

View File

@@ -10,12 +10,36 @@
**Models needing business_id:** Component, Brand, Product, Driver, Vehicle, Contact, Invoice
**Exception:** Orders span buyer + seller businesses - use `whereHas('items.product.brand')`
### 2. Route Prefixes
### 2. Route Prefixes & URL Structure
Check `docs/URL_STRUCTURE.md` BEFORE route changes.
- `/b/*` → Buyers only
- `/s/*` → Sellers only
- `/admin` → Super admins only
**CRITICAL: URL Identifier Rules**
- **Businesses**: ALWAYS use `slug` (e.g., `cannabrands`)
- **Brands**: ALWAYS use `slug` (e.g., `hash-factory`, `aloha-tymemachine`)
- **Products**: ALWAYS use `hashid` (e.g., `86qh2`, `52kn5`)
- **Hashids are for products ONLY** - never use for businesses or brands
**Standard Brand URL Patterns:**
```
Seller (managing their brands):
/s/cannabrands/brands/hash-factory/view → View brand details
/s/cannabrands/brands/hash-factory/edit → Edit brand
/s/cannabrands/brands/aloha-tymemachine/browse/preview → Preview menu
Buyer (browsing marketplace):
/b/brands → All brands list
/b/brands/hash-factory → View specific brand
/b/brands/hash-factory/86qh2 → View product (brand-slug + product-hashid)
/b/cannabrands/brands/hash-factory/browse → Browse seller's brand menu
```
**Key Differences:**
- Sellers manage: `/s/{business-slug}/brands/{brand-slug}/view|edit|preview`
- Buyers browse: `/b/brands/{brand-slug}` or `/b/{business-slug}/brands/{brand-slug}/browse`
### 3. Filament Usage Boundary
**Filament = `/admin` ONLY** (super admin tools)
**DO NOT** use Filament for `/b/` or `/s/` - use DaisyUI + Blade instead
@@ -121,3 +145,69 @@ Product::where('is_active', true)->get(); // No business_id filter!
✅ DaisyUI for buyer/seller, Filament only for admin
✅ NO inline styles - use Tailwind/DaisyUI classes only
✅ Run tests before committing
---
## Analytics System
### How It Works
Analytics tracking is **AUTOMATIC** on all buyer and public pages:
- `layouts/buyer-app-with-sidebar.blade.php` - All authenticated buyer pages
- `layouts/guest.blade.php` - All public/guest pages (registration, etc.)
**For product pages, pass the product to enable engagement tracking:**
```blade
@include('partials.analytics', ['product' => $product])
```
**For custom layouts, manually include:**
```blade
@include('partials.analytics')
```
### What Gets Tracked Automatically
Once included, the tracker automatically captures:
- Page views and time on page
- Scroll depth
- Session data
- Elements with `data-track-click` attribute
### Product Engagement Signals
On product pages, also tracks:
- Image zoom: `data-action="zoom-image"`
- Video views: `data-action="watch-video"`
- Spec downloads: `data-action="download-spec"`
- Add to cart: `data-action="add-to-cart"`
- Add to wishlist: `data-action="add-to-wishlist"`
### Adding Click Tracking
```blade
<button data-track-click
data-track-type="button"
data-track-id="cta-button"
data-track-label="Request Quote">
Request Quote
</button>
```
### Analytics Dashboard Routes
- `/s/{business}/analytics` - Overview dashboard
- `/s/{business}/analytics/products` - Product analytics
- `/s/{business}/analytics/buyers` - Buyer intelligence
- `/s/{business}/analytics/marketing` - Email campaigns
- `/s/{business}/analytics/sales` - Sales pipeline
### Key Files
- **Tracker**: `resources/views/partials/analytics.blade.php`
- **Controllers**: `app/Http/Controllers/Analytics/*`
- **Models**: `app/Models/Analytics/*`
- **Service**: `app/Services/AnalyticsTracker.php`
### Business Scoping
All analytics queries use explicit `forBusiness($businessId)` scoping:
```php
ProductView::forBusiness($business->id)->where(...)->get();
BuyerEngagementScore::forBusiness($business->id)->highValue()->get();
```
**See**: `ANALYTICS_QUICK_START.md` for detailed implementation examples

265
CLAUDE_CONTEXT.md Normal file
View File

@@ -0,0 +1,265 @@
# 🤖 Claude Code - Critical Context
**READ THIS FIRST before starting ANY work on this codebase.**
This file contains the architectural decisions, security patterns, and common mistakes that you MUST understand before making changes.
---
## 📚 Quick Navigation
- **[CLAUDE.md](CLAUDE.md)** - Common mistakes and critical rules (READ EVERY TIME)
- **[docs/README.md](docs/README.md)** - Full documentation index
- **[docs/architecture/](docs/architecture/)** - System architecture and design decisions
- **[docs/features/](docs/features/)** - Feature implementation guides
---
## 🚨 MOST CRITICAL RULES (Read Every Session)
### 1. Business Isolation Pattern (Security-Critical!)
```php
// ❌ WRONG - Vulnerable to cross-tenant data access
$component = Component::findOrFail($id);
if ($component->business_id !== $business->id) {
abort(403);
}
// ✅ RIGHT - Scope BEFORE finding
$component = Component::where('business_id', $business->id)->findOrFail($id);
```
**Why:** This prevents ID enumeration attacks across tenants. Always scope by `business_id` BEFORE querying.
**Models requiring business_id scoping:**
- Component, Brand, Product, Driver, Vehicle, Contact, Invoice
- ALL Analytics models (ProductView, ClickTracking, etc.)
**Exception:** Orders span buyer + seller businesses:
```php
// Buyer viewing their orders
Order::where('business_id', $business->id)->get();
// Seller viewing incoming orders
Order::whereHas('items.product.brand', fn($q) => $q->where('business_id', $business->id))->get();
```
### 2. URL Identifier Rules
| Resource | Identifier | Example |
|----------|-----------|---------|
| Business | `slug` | `/s/cannabrands/` |
| Brand | `slug` | `/brands/hash-factory/` |
| Product | `hashid` | `/products/86qh2` |
**NEVER mix these up!** Products use hashids, everything else uses slugs.
### 3. NO Global Scopes
```php
// ❌ We do NOT use:
protected static function booted() {
static::addGlobalScope(new BusinessScope);
}
// ✅ We use explicit scoping:
ProductView::where('business_id', $business->id)->get();
// ✅ Or scope methods:
ProductView::forBusiness($business->id)->get();
```
**Why:** Two-sided marketplace needs cross-business queries (buyers browse all sellers' products). Global scopes would break the marketplace.
### 4. Permission System
```php
// ✅ Use helper function
hasBusinessPermission('analytics.overview')
// ❌ NOT Spatie's can() yet
auth()->user()->can('analytics.overview') // Don't use
```
Permissions stored in: `business_user` pivot table, `permissions` JSON column.
### 5. NO Inline Styles
```php
// ❌ WRONG
<div style="background-color: #3b82f6; padding: 1rem;">
// ✅ RIGHT
<div class="bg-primary p-4">
```
**Exception:** Only for truly dynamic database values (e.g., user-uploaded brand colors).
---
## 🏗️ Architecture Overview
### Tech Stack by Area
| Area | Framework | Users | UI |
|------|-----------|-------|-----|
| `/admin` | Filament v3 | Super admins | Filament tables/forms |
| `/b/` | Blade + DaisyUI | Buyers | Custom marketplace |
| `/s/` | Blade + DaisyUI | Sellers | Custom CRM |
### Business Types
- `'buyer'` - Dispensary (browses marketplace, places orders)
- `'seller'` - Brand (manages products, fulfills orders)
- `'both'` - Vertically integrated
Users have `user_type` matching their business type.
### Multi-Business Users
```php
// Users can belong to MULTIPLE businesses
auth()->user()->businesses // BelongsToMany
// Get current business:
auth()->user()->primaryBusiness()
// Or use helpers:
currentBusiness()
currentBusinessId()
hasBusinessPermission($permission)
```
### Product → Brand → Business Hierarchy
```php
// Products DON'T have direct business_id
$product->brand->business_id
// For tracking, get seller's business:
$sellerBusiness = BusinessHelper::fromProduct($product);
```
---
## 🔒 Security Checklist
Before committing ANY code that touches multi-tenant data:
- [ ] Every query scopes by `business_id` BEFORE finding records
- [ ] Routes protected with proper middleware (`auth`, `verified`, `buyer`/`seller`)
- [ ] Permission checks use `hasBusinessPermission()` helper
- [ ] URL identifiers correct (slugs vs hashids)
- [ ] Tests verify business isolation
- [ ] No global scopes added
- [ ] NO inline styles (use Tailwind/DaisyUI)
---
## 🧪 Testing & Git
### Before Every Commit
```bash
php artisan test --parallel # REQUIRED
./vendor/bin/pint # REQUIRED
```
### Test Credentials
- `buyer@example.com` / `password`
- `seller@example.com` / `password`
- `admin@example.com` / `password`
### Git Workflow
- Never commit directly to `master`/`develop`
- Use feature branches: `feature/analytics-system`
- CI/CD: Woodpecker checks syntax → Pint → tests → Docker build
---
## 📋 Common Query Patterns
```php
// Seller viewing their products
Product::whereHas('brand', fn($q) => $q->where('business_id', $business->id))->get();
// Buyer viewing their orders
Order::where('business_id', $business->id)->get();
// Seller viewing incoming orders
Order::whereHas('items.product.brand', fn($q) => $q->where('business_id', $business->id))->get();
// Marketplace (cross-business - intentional!)
Product::where('is_active', true)->get(); // No business_id filter!
// Analytics scoping
ProductView::where('business_id', $business->id)
->whereBetween('viewed_at', [now()->subDays(30), now()])
->get();
```
---
## 📖 Detailed Documentation
For in-depth information, see:
### Architecture
- [docs/architecture/DATABASE.md](docs/architecture/DATABASE.md) - Database schema and relationships
- [docs/architecture/DATABASE_STRATEGY.md](docs/architecture/DATABASE_STRATEGY.md) - Multi-tenancy strategy
- [docs/architecture/URL_STRUCTURE.md](docs/architecture/URL_STRUCTURE.md) - URL patterns and routing
- [docs/architecture/API.md](docs/architecture/API.md) - API design
### Features
- [docs/features/ANALYTICS.md](docs/features/ANALYTICS.md) - Analytics System implementation
- [docs/features/FILE_STORAGE.md](docs/features/FILE_STORAGE.md) - File storage and product images
- [docs/features/BATCH_SYSTEM.md](docs/features/BATCH_SYSTEM.md) - Batch processing
- [docs/features/MANUFACTURING.md](docs/features/MANUFACTURING.md) - Manufacturing workflows
### Development
- [docs/development/SETUP.md](docs/development/SETUP.md) - Initial setup
- [docs/development/LOCAL_DEV.md](docs/development/LOCAL_DEV.md) - Local development
- [docs/development/DOCKER.md](docs/development/DOCKER.md) - Docker configuration
---
## 🎯 What You Often Forget
1. ✅ Scope by business_id BEFORE finding by ID
2. ✅ Use Eloquent (never raw SQL)
3. ✅ Protect routes with middleware
4. ✅ DaisyUI for buyer/seller UI (NOT Filament)
5. ✅ NO inline styles - Tailwind/DaisyUI classes only
6. ✅ Run tests before committing
7. ✅ Check URL identifier types (slug vs hashid)
8. ✅ Products go through Brand to get business_id
---
## 🐛 Common Issues
### "business_id cannot be null"
**Solution:** Make sure `currentBusinessId()` returns a value. User must be logged in and have a business.
### "Seeing other businesses' data"
**Solution:** You forgot to scope by business_id! Every query must have `where('business_id', ...)`.
### "Permission check not working"
**Solution:** Check `business_user.permissions` JSON array. Use `hasBusinessPermission()` helper.
### "Product has no business_id"
**Solution:** Products don't have direct business_id. Use `$product->brand->business_id` or `BusinessHelper::fromProduct($product)`.
---
## 🚀 Ready to Code!
Now read:
1. [CLAUDE.md](CLAUDE.md) for detailed rules and examples
2. [docs/README.md](docs/README.md) for full documentation index
3. The relevant feature guide in [docs/features/](docs/features/)
**Remember:** When in doubt, CHECK the business_id scoping! It's the #1 security issue in this codebase.

377
PERMISSIONS_ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,377 @@
# PERMISSION SYSTEM INVESTIGATION REPORT
## 1. BUSINESS SCOPING MECHANISM
### a) No Middleware Found
**FACT:** No middleware files with "business" or "tenant" in their names exist in `app/Http/Middleware/`
### b) Route-Level Business Scoping
**LOCATION:** [routes/seller.php:8-16](routes/seller.php#L8-L16)
**FACT:** Business scoping is enforced at the route binding level via custom route model binding:
```php
Route::bind('business', function (string $value) {
$business = \App\Models\Business::where('slug', $value)->firstOrFail();
// Verify user has access to this business
if (! auth()->check() || ! auth()->user()->businesses->contains($business->id)) {
abort(403, 'You do not have access to this business.');
}
return $business;
});
```
**RESULT:** When a route contains `{business}` parameter, this binding automatically:
- Resolves business by slug
- Verifies authenticated user belongs to that business
- Returns 403 if user doesn't have access
### c) Business Model - users() Relationship
**LOCATION:** [app/Models/Business.php:250-256](app/Models/Business.php#L250-L256)
**FACT:** Uses `business_user` pivot table with custom permissions:
```php
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class, 'business_user')
->using(BusinessUser::class)
->withPivot('contact_type', 'is_primary', 'permissions')
->withTimestamps();
}
```
**PIVOT COLUMNS:**
- `contact_type` - Role/contact type within business
- `is_primary` - Primary business for user
- `permissions` - JSON array of permission keys
### d) User Model - businesses() Relationship & Business Switching
**LOCATION:** [app/Models/User.php:174-177](app/Models/User.php#L174-L177)
**FACT:** Mirror relationship accessing same pivot:
```php
public function businesses(): BelongsToMany
{
return $this->companies(); // Alias for backwards compatibility
}
public function companies(): BelongsToMany
{
return $this->belongsToMany(Business::class, 'business_user')
->using(BusinessUser::class)
->withPivot('contact_type', 'is_primary', 'permissions')
->withTimestamps();
}
```
**PRIMARY BUSINESS METHOD:** [app/Models/User.php:131-142](app/Models/User.php#L131-L142)
```php
public function primaryBusiness()
{
// First try to get explicitly primary business
$primary = $this->businesses()->wherePivot('is_primary', true)->first();
// If no primary set, return the first business
if (! $primary) {
$primary = $this->businesses()->first();
}
return $primary;
}
```
## 2. CURRENT USER'S BUSINESS CONTEXT
### Session-Based Business Selection
**LOCATION:** [app/Helpers/BusinessHelper.php:14-28](app/Helpers/BusinessHelper.php#L14-L28)
**FACT:** Business context is stored in session under key `current_business_id`:
```php
public static function current(): ?Business
{
if (! Auth::check()) {
return null;
}
$businessId = session('current_business_id');
if ($businessId) {
return Business::find($businessId);
}
// Fall back to user's primary business if no session is set
return Auth::user()->primaryBusiness();
}
```
**BUSINESS SWITCHING:** [app/Http/Controllers/Seller/BrandSwitcherController.php:32](app/Http/Controllers/Seller/BrandSwitcherController.php#L32)
```php
session(['current_business_id' => $business->id]);
```
**HELPER FUNCTION:** [app/Helpers/helpers.php:5-10](app/Helpers/helpers.php#L5-L10)
```php
function currentBusiness() {
return BusinessHelper::current();
}
```
**USAGE PATTERN:**
1. User logs in → primary business is default
2. User switches businesses → stored in session as `current_business_id`
3. `currentBusiness()` helper retrieves from session OR falls back to primary
4. Route binding (`{business}` parameter) overrides this for scoped routes
## 3. PERMISSION CHECKING PATTERN
### Permission Storage: NOT in permissions table
**FACT:** Database `permissions` table is EMPTY (returned `[]`)
**ACTUAL STORAGE:** Permissions are stored in:
- Config file: `config/permissions.php` - Permission definitions
- Database pivot: `business_user.permissions` - JSON array per user per business
### Permission Service Architecture
**LOCATION:** [app/Services/PermissionService.php](app/Services/PermissionService.php)
**PERMISSION CHECK METHOD:** [app/Services/PermissionService.php:17-65](app/Services/PermissionService.php#L17-L65)
```php
public function check(User $user, string $permission, ?Business $business = null): bool
{
// Get business context
$business = $business ?? currentBusiness();
if (! $business) {
return false;
}
// Super admin bypass
if ($user->user_type === 'admin') {
return true;
}
// Business owner bypass
if ($business->owner_user_id === $user->id) {
return true;
}
// Get user's permissions for this business
$businessUser = $user->businesses()
->where('businesses.id', $business->id)
->first();
if (! $businessUser) {
return false;
}
$userPermissions = $businessUser->pivot->permissions ?? [];
// Check permission (supports wildcards)
return $this->hasPermissionInList($permission, $userPermissions);
}
```
**WILDCARD SUPPORT:** [app/Services/PermissionService.php:70-88](app/Services/PermissionService.php#L70-L88)
- Exact match: `analytics.overview`
- Wildcard match: `analytics.*` matches all `analytics.*` permissions
**HELPER FUNCTION:** [app/Helpers/BusinessHelper.php:38-55](app/Helpers/BusinessHelper.php#L38-L55)
```php
public static function hasPermission(string $permission): bool
{
if (! Auth::check()) {
return false;
}
$user = Auth::user();
$business = self::current();
if (! $business) {
return false;
}
// Use PermissionService for permission checking
$permissionService = app(PermissionService::class);
return $permissionService->check($user, $permission, $business);
}
```
**GLOBAL HELPER:** [app/Helpers/helpers.php:19-24](app/Helpers/helpers.php#L19-L24)
```php
function hasBusinessPermission(string $permission): bool {
return BusinessHelper::hasPermission($permission);
}
```
### Permission Checking in Filament
**FACT:** No `hasPermissionTo` or `->can()` calls found in `app/Filament/` directory
**EXPLANATION:** Filament resources use NO explicit permission checks. The admin panel is protected by:
- User type check: `$user->user_type === 'admin'` ([app/Models/User.php:112-113](app/Models/User.php#L112-L113))
- Admin panel is for super admins only, not business users
## 4. EXISTING PERMISSIONS LIST
**DATABASE QUERY RESULT:** `[]` (permissions table is EMPTY)
**ACTUAL PERMISSION DEFINITIONS:** Stored in `config/permissions.php`
### Permission Categories:
#### analytics (6 permissions):
- `analytics.overview` - View analytics overview
- `analytics.products` - View product analytics
- `analytics.marketing` - View marketing analytics
- `analytics.sales` - View sales analytics
- `analytics.buyers` - View buyer intelligence
- `analytics.export` - Export analytics data
#### products (5 permissions):
- `products.view` - View products
- `products.create` - Create products
- `products.edit` - Edit products
- `products.delete` - Delete products
- `products.pricing` - Manage pricing
#### orders (6 permissions):
- `orders.view` - View orders
- `orders.create` - Create orders
- `orders.edit` - Edit orders
- `orders.cancel` - Cancel orders
- `orders.fulfill` - Fulfill orders
- `orders.ship` - Ship orders
#### customers (5 permissions):
- `customers.view` - View customers
- `customers.create` - Create customers
- `customers.edit` - Edit customers
- `customers.delete` - Delete customers
- `customers.contact` - Contact customers
#### financial (7 permissions):
- `invoices.view` - View invoices
- `invoices.create` - Create invoices
- `invoices.edit` - Edit invoices
- `invoices.void` - Void invoices
- `payments.view` - View payments
- `payments.process` - Process payments
- `payments.refund` - Refund payments
#### users (5 permissions):
- `users.view` - View users
- `users.edit` - Edit users
- `users.permissions` - Manage permissions
- `users.view_as` - View as other users
- `settings.edit` - Edit settings
### Role Templates (Presets):
- `sales_rep` - Sales Representative
- `accountant` - Accountant
- `inventory_manager` - Inventory Manager
- `marketing_manager` - Marketing Manager
## 5. ROUTE STRUCTURE INVESTIGATION
### Seller Panel Routes
**LOCATION:** [routes/seller.php](routes/seller.php)
**PREFIX:** `/s/` for all seller routes
**STRUCTURE:**
- `/s/dashboard` - User-level dashboard (no business context)
- `/s/{business}/dashboard` - Business-scoped dashboard
- `/s/{business}/products/*` - Business-scoped product routes
- `/s/{business}/orders/*` - Business-scoped order routes
- `/s/{business}/customers/*` - Business-scoped customer routes
- `/s/{business}/analytics/*` - Business-scoped analytics routes
- `/s/{business}/settings/*` - Business-scoped settings
**MIDDLEWARE PROTECTION:**
- `seller` - User type must be 'seller'
- `auth` - User must be authenticated
- `verified` - Email verified
- `approved` - Business must be approved (for operational routes)
**BUSINESS SCOPING:** Routes under `/s/{business}/` automatically scope via route binding ([routes/seller.php:38-56](routes/seller.php#L38-L56))
### Buyer Panel Routes
**PREFIX:** `/b/` (mentioned in CLAUDE.md but not in seller.php)
### Admin Panel
**PREFIX:** `/admin` - Filament admin panel for super admins only
## SUMMARY - KEY ARCHITECTURAL PATTERNS
### Multi-Tenancy Implementation
**NOT** using spatie/laravel-multitenancy package
✅ Custom session-based business scoping
**How it works:**
1. User can belong to multiple businesses (many-to-many)
2. Each user has ONE primary business
3. User can switch between their businesses via session storage
4. Routes with `{business}` parameter enforce access control
5. Helper `currentBusiness()` returns session business OR primary business
### Permission System
**NOT** using Spatie permissions table
✅ Custom JSON-based permissions in pivot table
**How it works:**
1. Permissions defined in `config/permissions.php`
2. Granted per-user per-business in `business_user.permissions` JSON column
3. Checked via `PermissionService` with wildcard support
4. Super admin & business owner bypass all checks
5. Helper: `hasBusinessPermission('permission.key')`
### Business Scoping for Queries
**Pattern:** Manual `business_id` scoping using route-resolved business
**Example from route binding:** [routes/seller.php:48-53](routes/seller.php#L48-L53)
```php
$product = \App\Models\Product::where('hashid', $value)
->whereHas('brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})
->firstOrFail();
```
**Critical Rule:** Always scope by `business_id` BEFORE finding by ID to prevent cross-tenant access
ANSWER: Filament Usage in Your Codebase
Filament is ONLY used at /admin path
Evidence:
1. Single Filament Panel Configuration
app/Providers/Filament/AdminPanelProvider.php:33
return $panel
->default()
->id('admin')
->path('admin') // ← ONLY /admin path
2. No Filament in Controllers
Search result: No use Filament\ statements found in app/Http/Controllers/ 3. Seller Panel Uses Blade + DaisyUI
Found extensive Blade templates in resources/views/seller/:
seller/analytics/*.blade.php - Analytics views
seller/settings/*.blade.php - Settings views
seller/products/*.blade.php - Product management
seller/orders/*.blade.php - Order management
4. Filament Resources Directory Structure
app/Filament/
├── Resources/ ← Admin panel resources only
└── Widgets/ ← Admin panel widgets only
All these resources are auto-discovered by the admin panel only.
Conclusion
✅ CORRECT: Filament is exclusively for /admin (super admin panel)
✅ CORRECT: /s/ seller routes use Blade + DaisyUI
✅ CORRECT: Marketing/messaging features for sellers should NOT use Filament For the Marketing/Messaging implementation:
Use Blade templates in resources/views/seller/marketing/
Use DaisyUI components for UI
Use standard Laravel controllers in app/Http/Controllers/Seller/
Follow existing patterns from analytics/products/orders
Your implementation guide's Filament resources should be adapted to Blade + DaisyUI instead.

View File

@@ -0,0 +1,501 @@
# Analytics Implementation - Quick Handoff for Claude Code
## 🎯 Implementation Guide Location
**Main Technical Guide:** `/mnt/user-data/outputs/analytics-implementation-guide-REVISED.md`
This is a **REVISED** implementation that matches your ACTUAL Cannabrands architecture.
---
## ⚠️ CRITICAL ARCHITECTURAL DIFFERENCES
Your setup is different from typical Laravel multi-tenant apps:
### 1. **business_id is bigInteger (not UUID)**
```php
// Migration
$table->unsignedBigInteger('business_id')->index();
$table->foreign('business_id')->references('id')->on('businesses');
// NOT UUID like:
$table->uuid('tenant_id');
```
### 2. **NO Global Scopes - Explicit Scoping Pattern**
```php
// ❌ WRONG - Security vulnerability!
ProductView::findOrFail($id)
// ✅ RIGHT - Your pattern
ProductView::where('business_id', $business->id)->findOrFail($id)
// All queries MUST explicitly scope by business_id
```
### 3. **Permissions in business_user.permissions JSON Column**
```php
// NOT using Spatie permission routes yet
// Permissions stored in: business_user pivot table
// Column: 'permissions' => 'array' (JSON)
// Check permissions via helper:
hasBusinessPermission('analytics.overview')
// NOT via:
auth()->user()->can('analytics.overview') // ❌ Don't use this yet
```
### 4. **Multi-Business Users**
```php
// Users can belong to MULTIPLE businesses
auth()->user()->businesses // BelongsToMany
// Get current business:
auth()->user()->primaryBusiness()
// Or use helper:
currentBusiness()
currentBusinessId()
```
### 5. **Products → Brand → Business Hierarchy**
```php
// Products DON'T have direct business_id
// They go through Brand:
$product->brand->business_id
// For tracking product views, get seller's business:
$sellerBusiness = BusinessHelper::fromProduct($product);
```
### 6. **User Types via Middleware**
```php
// Routes use user_type middleware:
Route::middleware(['auth', 'verified', 'buyer']) // Buyers
Route::middleware(['auth', 'verified', 'seller']) // Sellers
Route::middleware(['auth', 'admin']) // Admins
// user_type values:
'buyer' => 'Buyer/Retailer'
'seller' => 'Seller/Brand'
'admin' => 'Super Admin'
```
### 7. **Reverb IS Configured (Horizon is NOT)**
```php
// ✅ Use Reverb for real-time updates
use App\Events\Analytics\HighIntentBuyerDetected;
event(new HighIntentBuyerDetected(...));
// ✅ Use Redis queues (already available)
CalculateEngagementScore::dispatch()->onQueue('analytics');
// ❌ Don't install Horizon (not needed yet)
```
---
## 📋 WHAT YOU'RE BUILDING
### Database Tables (7 migrations):
1. `analytics_events` - Raw event stream
2. `product_views` - Product engagement tracking
3. `email_campaigns` + `email_interactions` + `email_clicks` - Email tracking
4. `click_tracking` - General click events
5. `user_sessions` + `intent_signals` - Session & intent tracking
6. `buyer_engagement_scores` - Calculated buyer scores
7. `jobs` table for Redis queues
**Key Field:** Every table has `business_id` (bigInteger) with proper indexing
### Backend Components:
- **Helper Functions:** `currentBusiness()`, `hasBusinessPermission()`
- **AnalyticsTracker Service:** Main tracking service
- **Queue Jobs:** Async engagement score calculations
- **Events:** Reverb broadcasting for real-time updates
- **Controllers:** Dashboard, Products, Marketing, Sales, Buyers
- **Models:** 10 analytics models with explicit business scoping
### Frontend:
- Permission management UI in existing business/users section
- Analytics navigation (new top-level section)
- Dashboard views with KPIs and charts
- Real-time notifications via Reverb
---
## 🔐 SECURITY PATTERN (CRITICAL!)
**EVERY query MUST scope by business_id:**
```php
// ❌ NEVER do this - data leakage!
AnalyticsEvent::find($id)
ProductView::where('product_id', $productId)->get()
// ✅ ALWAYS do this - business isolated
AnalyticsEvent::where('business_id', $business->id)->find($id)
ProductView::where('business_id', $business->id)
->where('product_id', $productId)
->get()
// ✅ Or use scope helper in models
ProductView::forBusiness($business->id)->get()
```
---
## 🚀 IMPLEMENTATION STEPS
### 1. Create Helper Files First
```bash
# Create helpers
mkdir -p app/Helpers
# Copy BusinessHelper.php
# Copy helpers.php
# Update composer.json autoload.files
composer dump-autoload
```
### 2. Run Migrations
```bash
# Copy all 7 migration files
php artisan migrate
# Verify tables created
php artisan tinker
>>> DB::table('analytics_events')->count()
>>> DB::table('product_views')->count()
```
### 3. Create Models
```bash
mkdir -p app/Models/Analytics
# Copy all model files (10 models)
# Each model has explicit business scoping
```
### 4. Create Services
```bash
mkdir -p app/Services/Analytics
# Copy AnalyticsTracker service
```
### 5. Create Jobs
```bash
mkdir -p app/Jobs/Analytics
# Copy CalculateEngagementScore job
```
### 6. Create Events
```bash
mkdir -p app/Events/Analytics
# Copy HighIntentBuyerDetected event
# Update routes/channels.php for broadcasting
```
### 7. Create Controllers
```bash
mkdir -p app/Http/Controllers/Analytics
# Copy all controller files
```
### 8. Add Routes
```bash
# Update routes/web.php with analytics routes
# Use existing middleware patterns (auth, verified)
```
### 9. Update UI
```bash
# Add analytics navigation section
# Add permission management tile to business/users
# Create analytics dashboard views
```
### 10. Configure Queues
```bash
# Start queue worker
php artisan queue:work --queue=analytics
# (Reverb should already be running)
```
---
## 📊 TRACKING EXAMPLES
### Track Product View
```php
use App\Services\Analytics\AnalyticsTracker;
public function show(Product $product, Request $request)
{
$tracker = new AnalyticsTracker($request);
$view = $tracker->trackProductView($product);
// Queue engagement score calculation if buyer
if ($view && $view->buyer_business_id) {
\App\Jobs\Analytics\CalculateEngagementScore::dispatch(
$view->business_id,
$view->buyer_business_id
);
}
return view('products.show', compact('product'));
}
```
### JavaScript Click Tracking
```javascript
// Add to your main JS
document.addEventListener('click', function(e) {
const trackable = e.target.closest('[data-track-click]');
if (trackable) {
fetch('/api/analytics/track-click', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({
element_type: trackable.dataset.trackClick,
element_id: trackable.dataset.trackId
})
});
}
});
```
### HTML Usage
```blade
<a href="{{ route('products.show', $product) }}"
data-track-click="product_link"
data-track-id="{{ $product->id }}">
{{ $product->name }}
</a>
```
### Real-Time Notifications
```javascript
// In analytics dashboard
const businessId = {{ $business->id }};
Echo.channel('analytics.business.' + businessId)
.listen('.high-intent-buyer', (e) => {
showNotification('🔥 Hot Lead!', `${e.buyer_name} showing high intent`);
});
```
---
## 🔍 TESTING BUSINESS ISOLATION
```php
// In php artisan tinker
// 1. Login as user
auth()->loginUsingId(1);
$business = currentBusiness();
// 2. Test helper
echo "Business ID: " . currentBusinessId();
// 3. Test permission
echo hasBusinessPermission('analytics.overview') ? "✅ HAS" : "❌ NO";
// 4. Test scoping - should only return current business data
$count = App\Models\Analytics\ProductView::where('business_id', $business->id)->count();
echo "My views: $count";
// 5. Test auto-set business_id
$event = App\Models\Analytics\AnalyticsEvent::create([
'event_type' => 'test'
]);
echo $event->business_id === $business->id ? "✅ PASS" : "❌ FAIL";
```
---
## 📝 PERMISSION SETUP
Add permissions to a user:
```php
// In tinker or seeder
$user = User::find(1);
$business = $user->businesses->first();
// Grant analytics permissions
$user->businesses()->updateExistingPivot($business->id, [
'permissions' => [
'analytics.overview',
'analytics.products',
'analytics.marketing',
'analytics.sales',
'analytics.buyers',
'analytics.export'
]
]);
// Verify
$pivot = $user->businesses()->find($business->id)->pivot;
print_r($pivot->permissions);
```
---
## ⚡ QUEUE CONFIGURATION
Make sure Redis is running and queue worker is started:
```bash
# Check Redis
redis-cli ping
# Start queue worker
php artisan queue:work --queue=analytics --tries=3
# Or with supervisor (production):
[program:cannabrands-analytics-queue]
command=php /path/to/artisan queue:work --queue=analytics --tries=3
```
---
## 🎨 NAVIGATION UPDATE
Add to your sidebar navigation:
```blade
<!-- Analytics Section (New Top-Level) -->
<div class="nav-section">
<div class="nav-header">
<svg>...</svg>
Analytics
</div>
@if(hasBusinessPermission('analytics.overview'))
<a href="{{ route('analytics.dashboard') }}" class="nav-item">
Overview
</a>
@endif
@if(hasBusinessPermission('analytics.products'))
<a href="{{ route('analytics.products.index') }}" class="nav-item">
Products
</a>
@endif
<!-- Marketing, Sales, Buyers... -->
</div>
```
---
## 🐛 COMMON ISSUES
### Issue: "business_id cannot be null"
**Solution:** Make sure `currentBusinessId()` returns a value. User must be logged in and have a business.
### Issue: "Seeing other businesses' data"
**Solution:** You forgot to scope by business_id! Check your query has `where('business_id', ...)`.
### Issue: "Permission check not working"
**Solution:** Check the permissions array in business_user pivot table. Make sure it's a JSON array.
### Issue: "Product has no business_id"
**Solution:** Products don't have direct business_id. Use `BusinessHelper::fromProduct($product)` to get seller's business.
---
## 📚 FILE STRUCTURE
```
app/
├── Events/Analytics/
│ └── HighIntentBuyerDetected.php
├── Helpers/
│ ├── BusinessHelper.php
│ └── helpers.php
├── Http/Controllers/Analytics/
│ ├── AnalyticsDashboardController.php
│ ├── ProductAnalyticsController.php
│ ├── MarketingAnalyticsController.php
│ ├── SalesAnalyticsController.php
│ └── BuyerIntelligenceController.php
├── Jobs/Analytics/
│ └── CalculateEngagementScore.php
├── Models/Analytics/
│ ├── AnalyticsEvent.php
│ ├── ProductView.php
│ ├── EmailCampaign.php
│ ├── EmailInteraction.php
│ ├── EmailClick.php
│ ├── ClickTracking.php
│ ├── UserSession.php
│ ├── IntentSignal.php
│ └── BuyerEngagementScore.php
└── Services/Analytics/
└── AnalyticsTracker.php
database/migrations/
├── 2024_01_01_000001_create_analytics_events_table.php
├── 2024_01_01_000002_create_product_views_table.php
├── 2024_01_01_000003_create_email_tracking_tables.php
├── 2024_01_01_000004_create_click_tracking_table.php
├── 2024_01_01_000005_create_user_sessions_and_intent_tables.php
├── 2024_01_01_000006_add_analytics_permissions_to_business_user.php
└── 2024_01_01_000007_create_analytics_jobs_table.php
resources/views/analytics/
├── dashboard.blade.php
├── products/
│ ├── index.blade.php
│ └── show.blade.php
├── marketing/
├── sales/
└── buyers/
routes/
├── channels.php (add broadcasting channel)
└── web.php (add analytics routes)
```
---
## ✅ DEFINITION OF DONE
- [ ] All 7 migrations run successfully
- [ ] BusinessHelper and helpers.php created and autoloaded
- [ ] All 10 analytics models created with business scoping
- [ ] AnalyticsTracker service working
- [ ] Queue jobs configured and tested
- [ ] Reverb events broadcasting
- [ ] All 5 controllers created
- [ ] Routes added with permission checks
- [ ] Navigation updated with Analytics section
- [ ] Permission UI tile added
- [ ] At least one dashboard view working
- [ ] Business isolation verified (no cross-business data)
- [ ] Permission checking works via business_user pivot
- [ ] Queue worker running for analytics jobs
- [ ] Test data can be created and viewed
---
## 🎉 READY TO IMPLEMENT!
Everything in the main guide is tailored to YOUR actual architecture:
- ✅ business_id (bigInteger) not UUID
- ✅ Explicit scoping, no global scopes
- ✅ business_user.permissions JSON
- ✅ Multi-business user support
- ✅ Product → Brand → Business hierarchy
- ✅ Reverb for real-time
- ✅ Redis queues (no Horizon needed)
**Estimated implementation time: 5-6 hours**
Start with helpers and migrations, then build up from there! 🚀

104
analyze-old-schema.php Normal file
View File

@@ -0,0 +1,104 @@
<?php
$sql = file_get_contents(__DIR__.'/hubexport.sql');
echo "=== ANALYZING OLD MYSQL DATABASE SCHEMA ===\n\n";
// Extract CREATE TABLE for brands
if (preg_match('/CREATE TABLE `brands` \((.*?)\) ENGINE=/s', $sql, $match)) {
echo "OLD MYSQL BRANDS TABLE COLUMNS:\n";
$columns = $match[1];
// Split by comma but not inside parentheses
preg_match_all('/`(\w+)`\s+(\w+)(?:\(.*?\))?(?:\s+(.*?))?(?=,\s*`|\s*$)/s', $columns, $matches, PREG_SET_ORDER);
foreach ($matches as $col) {
$name = $col[1];
$type = $col[2];
$extra = isset($col[3]) ? trim($col[3]) : '';
echo " - $name: $type $extra\n";
}
echo "\n";
}
// Extract CREATE TABLE for products
if (preg_match('/CREATE TABLE `products` \((.*?)\) ENGINE=/s', $sql, $match)) {
echo "OLD MYSQL PRODUCTS TABLE COLUMNS:\n";
$columns = $match[1];
preg_match_all('/`(\w+)`\s+(\w+)(?:\(.*?\))?(?:\s+(.*?))?(?=,\s*`|\s*$)/s', $columns, $matches, PREG_SET_ORDER);
foreach (array_slice($matches, 0, 30) as $col) {
$name = $col[1];
$type = $col[2];
$extra = isset($col[3]) ? trim($col[3]) : '';
echo " - $name: $type $extra\n";
}
echo "\n";
}
// Extract CREATE TABLE for product_images
if (preg_match('/CREATE TABLE `product_images` \((.*?)\) ENGINE=/s', $sql, $match)) {
echo "OLD MYSQL PRODUCT_IMAGES TABLE COLUMNS:\n";
$columns = $match[1];
preg_match_all('/`(\w+)`\s+(\w+)(?:\(.*?\))?(?:\s+(.*?))?(?=,\s*`|\s*$)/s', $columns, $matches, PREG_SET_ORDER);
foreach ($matches as $col) {
$name = $col[1];
$type = $col[2];
$extra = isset($col[3]) ? trim($col[3]) : '';
echo " - $name: $type $extra\n";
}
echo "\n";
}
// Extract CREATE TABLE for product_prices
if (preg_match('/CREATE TABLE `product_prices` \((.*?)\) ENGINE=/s', $sql, $match)) {
echo "OLD MYSQL PRODUCT_PRICES TABLE COLUMNS:\n";
$columns = $match[1];
preg_match_all('/`(\w+)`\s+(\w+)(?:\(.*?\))?(?:\s+(.*?))?(?=,\s*`|\s*$)/s', $columns, $matches, PREG_SET_ORDER);
foreach ($matches as $col) {
$name = $col[1];
$type = $col[2];
$extra = isset($col[3]) ? trim($col[3]) : '';
echo " - $name: $type $extra\n";
}
echo "\n";
}
// Extract CREATE TABLE for product_variations
if (preg_match('/CREATE TABLE `product_variations` \((.*?)\) ENGINE=/s', $sql, $match)) {
echo "OLD MYSQL PRODUCT_VARIATIONS TABLE COLUMNS:\n";
$columns = $match[1];
preg_match_all('/`(\w+)`\s+(\w+)(?:\(.*?\))?(?:\s+(.*?))?(?=,\s*`|\s*$)/s', $columns, $matches, PREG_SET_ORDER);
foreach ($matches as $col) {
$name = $col[1];
$type = $col[2];
$extra = isset($col[3]) ? trim($col[3]) : '';
echo " - $name: $type $extra\n";
}
echo "\n";
}
// Extract CREATE TABLE for labs
if (preg_match('/CREATE TABLE `labs` \((.*?)\) ENGINE=/s', $sql, $match)) {
echo "OLD MYSQL LABS TABLE COLUMNS:\n";
$columns = $match[1];
preg_match_all('/`(\w+)`\s+(\w+)(?:\(.*?\))?(?:\s+(.*?))?(?=,\s*`|\s*$)/s', $columns, $matches, PREG_SET_ORDER);
foreach ($matches as $col) {
$name = $col[1];
$type = $col[2];
$extra = isset($col[3]) ? trim($col[3]) : '';
echo " - $name: $type $extra\n";
}
echo "\n";
}
// Extract CREATE TABLE for product_extras
if (preg_match('/CREATE TABLE `product_extras` \((.*?)\) ENGINE=/s', $sql, $match)) {
echo "OLD MYSQL PRODUCT_EXTRAS TABLE COLUMNS:\n";
$columns = $match[1];
preg_match_all('/`(\w+)`\s+(\w+)(?:\(.*?\))?(?:\s+(.*?))?(?=,\s*`|\s*$)/s', $columns, $matches, PREG_SET_ORDER);
foreach ($matches as $col) {
$name = $col[1];
$type = $col[2];
$extra = isset($col[3]) ? trim($col[3]) : '';
echo " - $name: $type $extra\n";
}
echo "\n";
}

View File

@@ -0,0 +1,155 @@
<?php
namespace App\Console\Commands;
use App\Models\PermissionAuditLog;
use Illuminate\Console\Command;
class CleanupPermissionAuditLogs extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'permissions:cleanup-audit
{--dry-run : Show what would be deleted without actually deleting}
{--force : Skip confirmation prompt}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Delete expired permission audit logs (non-critical logs past their expiration date)';
/**
* Execute the console command.
*/
public function handle(): int
{
$isDryRun = $this->option('dry-run');
$isForced = $this->option('force');
$this->info('🔍 Scanning for expired permission audit logs...');
$this->newLine();
// Find expired logs
$expiredLogs = PermissionAuditLog::expired()->get();
if ($expiredLogs->isEmpty()) {
$this->info('✅ No expired audit logs found. Everything is up to date!');
return self::SUCCESS;
}
// Statistics
$totalCount = $expiredLogs->count();
$oldestLog = $expiredLogs->sortBy('created_at')->first();
$newestLog = $expiredLogs->sortByDesc('created_at')->first();
// Display summary
$this->table(
['Metric', 'Value'],
[
['Expired logs found', $totalCount],
['Oldest expired log', $oldestLog->created_at->format('Y-m-d H:i:s')],
['Newest expired log', $newestLog->created_at->format('Y-m-d H:i:s')],
['Date range', $oldestLog->created_at->diffForHumans($newestLog->created_at, true)],
]
);
$this->newLine();
// Show sample of logs to be deleted
$this->info('📋 Sample of logs to be deleted:');
$sampleLogs = $expiredLogs->take(5);
foreach ($sampleLogs as $log) {
$this->line(sprintf(
' • [%s] %s - %s (expired %s)',
$log->created_at->format('Y-m-d'),
$log->action_name,
$log->targetUser?->name ?? 'Unknown User',
$log->expires_at->diffForHumans()
));
}
if ($totalCount > 5) {
$this->line(" ... and {$totalCount} more");
}
$this->newLine();
// Dry run mode
if ($isDryRun) {
$this->warn('🧪 DRY RUN MODE - No logs will be deleted');
$this->info("Would delete {$totalCount} expired audit logs");
return self::SUCCESS;
}
// Confirmation prompt (unless forced)
if (! $isForced) {
$confirmed = $this->confirm(
"Are you sure you want to delete {$totalCount} expired audit logs?",
false
);
if (! $confirmed) {
$this->info('❌ Cleanup cancelled');
return self::SUCCESS;
}
}
// Perform deletion
$this->info('🗑️ Deleting expired audit logs...');
$progressBar = $this->output->createProgressBar($totalCount);
$progressBar->start();
$deletedCount = 0;
$errorCount = 0;
foreach ($expiredLogs as $log) {
try {
$log->delete();
$deletedCount++;
} catch (\Exception $e) {
$errorCount++;
$this->error("Failed to delete log ID {$log->id}: {$e->getMessage()}");
}
$progressBar->advance();
}
$progressBar->finish();
$this->newLine(2);
// Final summary
if ($errorCount === 0) {
$this->info("✅ Successfully deleted {$deletedCount} expired audit logs");
} else {
$this->warn("⚠️ Deleted {$deletedCount} logs with {$errorCount} errors");
}
// Show remaining stats
$remainingTotal = PermissionAuditLog::count();
$remainingCritical = PermissionAuditLog::critical()->count();
$remainingNonExpired = $remainingTotal - $remainingCritical;
$this->newLine();
$this->info('📊 Database statistics after cleanup:');
$this->table(
['Category', 'Count'],
[
['Critical logs (kept forever)', $remainingCritical],
['Non-critical logs (not yet expired)', $remainingNonExpired],
['Total remaining logs', $remainingTotal],
]
);
return self::SUCCESS;
}
}

View File

@@ -26,7 +26,10 @@ class Kernel extends ConsoleKernel
*/
protected function schedule(Schedule $schedule)
{
// $schedule->command('inspire')->hourly();
// Check for scheduled broadcasts every minute
$schedule->job(new \App\Jobs\Marketing\ProcessScheduledBroadcastsJob)
->everyMinute()
->withoutOverlapping();
}
/**

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Events;
use App\Models\Analytics\BuyerEngagementScore;
use App\Models\Analytics\IntentSignal;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class HighIntentBuyerDetected implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public int $sellerBusinessId,
public int $buyerBusinessId,
public IntentSignal $signal,
public ?BuyerEngagementScore $engagementScore = null
) {}
/**
* Get the channels the event should broadcast on.
*/
public function broadcastOn(): Channel
{
return new Channel("business.{$this->sellerBusinessId}.analytics");
}
/**
* Get the data to broadcast.
*/
public function broadcastWith(): array
{
return [
'buyer_business_id' => $this->buyerBusinessId,
'buyer_business_name' => $this->signal->buyerBusiness?->name,
'signal_type' => $this->signal->signal_type,
'signal_strength' => $this->signal->signal_strength,
'product_id' => $this->signal->subject_type === 'App\Models\Product' ? $this->signal->subject_id : null,
'total_engagement_score' => $this->engagementScore?->total_score,
'detected_at' => $this->signal->detected_at->toIso8601String(),
'context' => $this->signal->context,
];
}
/**
* The event's broadcast name.
*/
public function broadcastAs(): string
{
return 'high-intent-buyer-detected';
}
}

View File

@@ -0,0 +1,145 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\TemplateCategoryResource\Pages;
use App\Models\Marketing\TemplateCategory;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Forms;
use Filament\Forms\Components\ColorPicker;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\ColorColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Support\Str;
class TemplateCategoryResource extends Resource
{
protected static ?string $model = TemplateCategory::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-folder';
protected static \UnitEnum|string|null $navigationGroup = 'Marketing';
protected static ?int $navigationSort = 1;
protected static ?string $navigationLabel = 'Template Categories';
protected static ?string $modelLabel = 'Template Category';
protected static ?string $pluralModelLabel = 'Template Categories';
public static function form(Schema $schema): Schema
{
return $schema
->components([
Grid::make(2)
->schema([
TextInput::make('name')
->label('Category Name')
->required()
->maxLength(255)
->live(onBlur: true)
->afterStateUpdated(function (string $operation, $state, Forms\Set $set) {
if ($operation === 'create') {
$set('slug', Str::slug($state));
}
}),
TextInput::make('slug')
->label('Slug')
->required()
->maxLength(255)
->unique(ignoreRecord: true)
->alphaDash(),
TextInput::make('icon')
->label('Icon')
->helperText('Heroicon name (e.g., heroicon-o-envelope)')
->maxLength(255),
ColorPicker::make('color')
->label('Color')
->helperText('Category color for visual organization'),
TextInput::make('sort_order')
->label('Sort Order')
->numeric()
->default(0)
->helperText('Lower numbers appear first'),
]),
Textarea::make('description')
->label('Description')
->rows(3)
->columnSpanFull(),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->label('Category')
->searchable()
->sortable(),
TextColumn::make('slug')
->label('Slug')
->searchable()
->sortable(),
ColorColumn::make('color')
->label('Color'),
TextColumn::make('icon')
->label('Icon')
->badge(),
TextColumn::make('templates_count')
->label('Templates')
->counts('templates')
->sortable(),
TextColumn::make('sort_order')
->label('Order')
->sortable(),
TextColumn::make('created_at')
->label('Created')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
])
->actions([
EditAction::make(),
DeleteAction::make(),
])
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
])
->defaultSort('sort_order');
}
public static function getPages(): array
{
return [
'index' => Pages\ListTemplateCategories::route('/'),
'create' => Pages\CreateTemplateCategory::route('/create'),
'edit' => Pages\EditTemplateCategory::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\TemplateCategoryResource\Pages;
use App\Filament\Resources\TemplateCategoryResource;
use Filament\Resources\Pages\CreateRecord;
class CreateTemplateCategory extends CreateRecord
{
protected static string $resource = TemplateCategoryResource::class;
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\TemplateCategoryResource\Pages;
use App\Filament\Resources\TemplateCategoryResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditTemplateCategory extends EditRecord
{
protected static string $resource = TemplateCategoryResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\TemplateCategoryResource\Pages;
use App\Filament\Resources\TemplateCategoryResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListTemplateCategories extends ListRecords
{
protected static string $resource = TemplateCategoryResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,331 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\TemplateResource\Pages;
use App\Models\Marketing\Template;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ViewAction;
use Filament\Forms\Components\RichEditor;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
class TemplateResource extends Resource
{
protected static ?string $model = Template::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-envelope';
protected static \UnitEnum|string|null $navigationGroup = 'Marketing';
protected static ?int $navigationSort = 2;
protected static ?string $navigationLabel = 'Email Templates';
protected static ?string $modelLabel = 'Email Template';
protected static ?string $pluralModelLabel = 'Email Templates';
public static function getNavigationBadge(): ?string
{
// Temporarily disabled for performance testing
return null;
// return cache()->remember('template_system_count', 60, function () {
// return static::getModel()::where('is_system_template', true)->count() ?: null;
// });
}
public static function form(Schema $schema): Schema
{
return $schema
->components([
Tabs::make('Template Details')
->tabs([
Tab::make('Basic Information')
->schema([
Grid::make(2)
->schema([
Select::make('business_id')
->label('Business')
->relationship('business', 'name')
->required()
->searchable()
->preload()
->helperText('Business that owns this template'),
Select::make('category_id')
->label('Category')
->relationship('category', 'name')
->required()
->searchable()
->preload()
->createOptionForm([
TextInput::make('name')->required(),
TextInput::make('slug')->required(),
Textarea::make('description'),
]),
TextInput::make('name')
->label('Template Name')
->required()
->maxLength(255)
->columnSpanFull(),
Textarea::make('description')
->label('Description')
->rows(3)
->columnSpanFull(),
]),
Grid::make(3)
->schema([
Select::make('template_type')
->label('Type')
->options([
'email' => 'Email',
'sms' => 'SMS',
'push' => 'Push Notification',
])
->required()
->default('email'),
Toggle::make('is_system_template')
->label('System Template')
->helperText('Available to all businesses')
->default(false),
Toggle::make('is_public')
->label('Public Template')
->helperText('Visible in template library')
->default(false),
]),
]),
Tab::make('Content')
->schema([
RichEditor::make('html_content')
->label('HTML Content')
->required()
->columnSpanFull()
->fileAttachmentsDirectory('template-assets'),
Textarea::make('plain_text')
->label('Plain Text Version')
->helperText('Fallback for email clients that don\'t support HTML')
->rows(10)
->columnSpanFull(),
Textarea::make('mjml_content')
->label('MJML Source')
->helperText('Optional: MJML source code for the template')
->rows(10)
->columnSpanFull(),
]),
Tab::make('Settings')
->schema([
Grid::make(2)
->schema([
TextInput::make('thumbnail')
->label('Thumbnail URL')
->url()
->maxLength(255),
TextInput::make('version')
->label('Version')
->numeric()
->default(1)
->disabled(),
TextInput::make('usage_count')
->label('Usage Count')
->numeric()
->default(0)
->disabled(),
TextInput::make('usage_limit')
->label('Usage Limit')
->numeric()
->minValue(0)
->helperText('Maximum number of times this template can be used (leave empty for unlimited)'),
Select::make('usage_period')
->label('Usage Period')
->options([
'monthly' => 'Monthly',
'yearly' => 'Yearly',
'lifetime' => 'Lifetime',
])
->helperText('Time period for usage limit')
->visible(fn ($get) => filled($get('usage_limit'))),
Toggle::make('is_premium')
->label('Premium Template')
->helperText('Requires premium subscription to use')
->default(false),
Select::make('created_by')
->label('Created By')
->relationship('creator', 'email')
->disabled(),
]),
Textarea::make('tags')
->label('Tags (JSON)')
->helperText('Enter tags as JSON array: ["tag1", "tag2"]')
->rows(3)
->columnSpanFull(),
Textarea::make('design_json')
->label('Design JSON')
->helperText('JSON representation of the template design')
->rows(10)
->columnSpanFull(),
]),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->modifyQueryUsing(fn ($query) => $query->with(['business', 'category', 'creator']))
->columns([
TextColumn::make('name')
->label('Template')
->searchable()
->sortable(),
TextColumn::make('category.name')
->label('Category')
->badge()
->searchable()
->sortable(),
TextColumn::make('business.name')
->label('Business')
->searchable()
->sortable()
->toggleable(),
TextColumn::make('template_type')
->label('Type')
->badge()
->color(fn (string $state): string => match ($state) {
'email' => 'success',
'sms' => 'warning',
'push' => 'info',
default => 'gray',
})
->sortable(),
IconColumn::make('is_system_template')
->label('System')
->boolean()
->sortable(),
IconColumn::make('is_public')
->label('Public')
->boolean()
->sortable(),
IconColumn::make('is_premium')
->label('Premium')
->boolean()
->sortable(),
TextColumn::make('usage_count')
->label('Uses')
->numeric()
->sortable(),
TextColumn::make('usage_limit')
->label('Limit')
->numeric()
->sortable()
->default('Unlimited')
->formatStateUsing(fn ($state) => $state ?? 'Unlimited'),
TextColumn::make('last_used_at')
->label('Last Used')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('created_at')
->label('Created')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
TernaryFilter::make('is_system_template')
->label('System Template')
->placeholder('All templates')
->trueLabel('System templates only')
->falseLabel('Business templates only'),
TernaryFilter::make('is_public')
->label('Public Template')
->placeholder('All visibility')
->trueLabel('Public only')
->falseLabel('Private only'),
SelectFilter::make('category_id')
->label('Category')
->relationship('category', 'name')
->searchable()
->preload(),
SelectFilter::make('business_id')
->label('Business')
->relationship('business', 'name')
->searchable()
->preload(),
SelectFilter::make('template_type')
->label('Type')
->options([
'email' => 'Email',
'sms' => 'SMS',
'push' => 'Push',
]),
])
->actions([
ViewAction::make(),
EditAction::make(),
DeleteAction::make(),
])
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
])
->defaultSort('created_at', 'desc');
}
public static function getPages(): array
{
return [
'index' => Pages\ListTemplates::route('/'),
'create' => Pages\CreateTemplate::route('/create'),
'view' => Pages\ViewTemplate::route('/{record}'),
'edit' => Pages\EditTemplate::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\TemplateResource\Pages;
use App\Filament\Resources\TemplateResource;
use Filament\Resources\Pages\CreateRecord;
class CreateTemplate extends CreateRecord
{
protected static string $resource = TemplateResource::class;
protected function mutateFormDataBeforeCreate(array $data): array
{
$data['created_by'] = auth()->id();
$data['version'] = 1;
return $data;
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Filament\Resources\TemplateResource\Pages;
use App\Filament\Resources\TemplateResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditTemplate extends EditRecord
{
protected static string $resource = TemplateResource::class;
protected function getHeaderActions(): array
{
return [
Actions\ViewAction::make(),
Actions\DeleteAction::make(),
];
}
protected function mutateFormDataBeforeSave(array $data): array
{
$data['updated_by'] = auth()->id();
return $data;
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\TemplateResource\Pages;
use App\Filament\Resources\TemplateResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListTemplates extends ListRecords
{
protected static string $resource = TemplateResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Filament\Resources\TemplateResource\Pages;
use App\Filament\Resources\TemplateResource;
use Filament\Actions;
use Filament\Resources\Pages\ViewRecord;
class ViewTemplate extends ViewRecord
{
protected static string $resource = TemplateResource::class;
protected function getHeaderActions(): array
{
return [
Actions\EditAction::make(),
Actions\DeleteAction::make(),
];
}
}

View File

@@ -85,6 +85,22 @@ class UserResource extends Resource
'suspended' => 'Suspended',
])
->default('active'),
TextInput::make('password')
->label('Password')
->password()
->required(fn ($record) => $record === null)
->dehydrated(fn ($state) => filled($state))
->minLength(8)
->maxLength(255)
->helperText('Leave blank to keep current password when editing')
->visible(fn ($livewire) => $livewire instanceof CreateUser),
TextInput::make('password_confirmation')
->label('Confirm Password')
->password()
->required(fn ($record) => $record === null && filled($record?->password))
->dehydrated(false)
->same('password')
->visible(fn ($livewire) => $livewire instanceof CreateUser),
])->columns(2),
Section::make('Business Association')

View File

@@ -4,8 +4,18 @@ namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Support\Facades\Hash;
class CreateUser extends CreateRecord
{
protected static string $resource = UserResource::class;
protected function mutateFormDataBeforeCreate(array $data): array
{
if (isset($data['password'])) {
$data['password'] = Hash::make($data['password']);
}
return $data;
}
}

View File

@@ -0,0 +1,143 @@
<?php
namespace App\Helpers;
use App\Models\Business;
use App\Services\PermissionService;
use Illuminate\Support\Facades\Auth;
class BusinessHelper
{
/**
* Get current business context from session or user's primary business
*/
public static function current(): ?Business
{
if (! Auth::check()) {
return null;
}
$businessId = session('current_business_id');
if ($businessId) {
return Business::find($businessId);
}
// Fall back to user's primary business if no session is set
return Auth::user()->primaryBusiness();
}
/**
* Check if user has a permission for current business
*
* This method now uses PermissionService internally for better architecture
* while maintaining backward compatibility with existing code.
*
* @param string $permission Permission key (e.g. 'analytics.overview')
*/
public static function hasPermission(string $permission): bool
{
if (! Auth::check()) {
return false;
}
$user = Auth::user();
$business = self::current();
if (! $business) {
return false;
}
// Use PermissionService for permission checking
$permissionService = app(PermissionService::class);
return $permissionService->check($user, $permission, $business);
}
/**
* Check if user is owner or admin for current business
*/
public static function isOwnerOrAdmin(): bool
{
if (! Auth::check()) {
return false;
}
$user = Auth::user();
$business = self::current();
if (! $business) {
return false;
}
// Super admin
if ($user->user_type === 'admin') {
return true;
}
// Business owner
return $business->owner_user_id === $user->id;
}
/**
* Get user's role template for current business
*/
public static function getRoleTemplate(): ?string
{
if (! Auth::check()) {
return null;
}
$user = Auth::user();
$business = self::current();
if (! $business) {
return null;
}
$businessUser = $user->businesses()
->where('businesses.id', $business->id)
->first();
return $businessUser?->pivot->role_template;
}
/**
* Get user's permissions array for current business
*/
public static function getPermissions(): array
{
if (! Auth::check()) {
return [];
}
$user = Auth::user();
$business = self::current();
if (! $business) {
return [];
}
// Use PermissionService for cached permission retrieval
$permissionService = app(PermissionService::class);
return $permissionService->getUserPermissions($user, $business);
}
/**
* Check if current business has a specific module enabled
*
* @param string $module Module name (sales, manufacturing, compliance)
*/
public static function hasModule(string $module): bool
{
$business = self::current();
return match ($module) {
'sales' => true, // Sales is always enabled (base product)
'manufacturing' => $business?->has_manufacturing ?? false,
'compliance' => $business?->has_compliance ?? false,
default => false,
};
}
}

24
app/Helpers/helpers.php Normal file
View File

@@ -0,0 +1,24 @@
<?php
use App\Helpers\BusinessHelper;
if (! function_exists('currentBusiness')) {
function currentBusiness()
{
return BusinessHelper::current();
}
}
if (! function_exists('currentBusinessId')) {
function currentBusinessId()
{
return BusinessHelper::currentId();
}
}
if (! function_exists('hasBusinessPermission')) {
function hasBusinessPermission(string $permission): bool
{
return BusinessHelper::hasPermission($permission);
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Http\Controllers;
use App\Models\Business;
use App\Models\SubscriptionInvoice;
class AdminController extends Controller
{
public function billingOverview()
{
// Get all businesses with their latest subscriptions and invoices
$businesses = Business::with([
'subscription',
'subscription.defaultPaymentMethod',
])
->select('id', 'name', 'slug', 'email', 'created_at')
->orderBy('name')
->get()
->map(function ($business) {
$subscription = $business->subscription;
// Get invoices for this business
$totalRevenue = SubscriptionInvoice::where('business_id', $business->id)
->where('status', 'paid')
->sum('amount');
$pendingAmount = SubscriptionInvoice::where('business_id', $business->id)
->where('status', 'pending')
->sum('amount');
$pastDueAmount = SubscriptionInvoice::where('business_id', $business->id)
->where('status', 'past_due')
->sum('amount');
return [
'id' => $business->id,
'name' => $business->name,
'slug' => $business->slug,
'email' => $business->email,
'joined' => $business->created_at,
'plan_id' => $subscription?->plan_id ?? 'none',
'plan_name' => $subscription?->plan_name ?? 'No Plan',
'plan_price' => $subscription?->plan_price ?? 0,
'subscription_status' => $subscription?->status ?? 'inactive',
'current_period_end' => $subscription?->current_period_end,
'has_scheduled_downgrade' => $subscription?->hasScheduledDowngrade() ?? false,
'scheduled_plan_name' => $subscription?->scheduled_plan_name,
'scheduled_change_date' => $subscription?->scheduled_change_date,
'total_revenue' => $totalRevenue,
'pending_amount' => $pendingAmount,
'past_due_amount' => $pastDueAmount,
'payment_method' => $subscription?->defaultPaymentMethod
? $subscription->defaultPaymentMethod->card_brand.' •••• '.$subscription->defaultPaymentMethod->last4
: 'None',
];
});
// Calculate summary stats
$stats = [
'total_businesses' => $businesses->count(),
'active_subscriptions' => $businesses->where('subscription_status', 'active')->count(),
'total_mrr' => $businesses->where('subscription_status', 'active')->sum('plan_price'),
'total_revenue' => $businesses->sum('total_revenue'),
'total_pending' => $businesses->sum('pending_amount'),
'total_past_due' => $businesses->sum('past_due_amount'),
];
return view('admin.billing-overview', compact('businesses', 'stats'));
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace App\Http\Controllers\Analytics;
use App\Http\Controllers\Controller;
use App\Models\Analytics\AnalyticsEvent;
use App\Models\Analytics\BuyerEngagementScore;
use App\Models\Analytics\IntentSignal;
use App\Models\Analytics\ProductView;
use App\Models\Analytics\UserSession;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class AnalyticsDashboardController extends Controller
{
public function index(Request $request)
{
if (! hasBusinessPermission('analytics.overview')) {
abort(403, 'Unauthorized to view analytics');
}
$business = currentBusiness();
$period = $request->input('period', '30'); // days
$startDate = now()->subDays((int) $period);
// Key metrics
$metrics = [
'total_sessions' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)->count(),
'total_page_views' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)->sum('page_views'),
'total_product_views' => ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)->count(),
'unique_products_viewed' => ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)
->distinct('product_id')
->count('product_id'),
'high_intent_signals' => IntentSignal::forBusiness($business->id)->where('detected_at', '>=', $startDate)
->where('signal_strength', '>=', IntentSignal::STRENGTH_HIGH)
->count(),
'active_buyers' => BuyerEngagementScore::forBusiness($business->id)->where('last_interaction_at', '>=', $startDate)->count(),
];
// Traffic trend (daily breakdown)
$trafficTrend = AnalyticsEvent::forBusiness($business->id)->where('created_at', '>=', $startDate)
->select(
DB::raw('DATE(created_at) as date'),
DB::raw('COUNT(*) as total_events'),
DB::raw('COUNT(DISTINCT session_id) as unique_sessions')
)
->groupBy('date')
->orderBy('date')
->get();
// Top products by views
$topProducts = ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)
->select('product_id', DB::raw('COUNT(*) as view_count'))
->groupBy('product_id')
->orderByDesc('view_count')
->limit(10)
->with('product')
->get();
// High-value buyers
$highValueBuyers = BuyerEngagementScore::forBusiness($business->id)->highValue()
->active()
->orderByDesc('score')
->limit(10)
->with('buyerBusiness')
->get();
// Recent high-intent signals
$recentIntentSignals = IntentSignal::forBusiness($business->id)->highIntent()
->where('detected_at', '>=', now()->subHours(24))
->orderByDesc('detected_at')
->limit(10)
->with(['buyerBusiness', 'user'])
->get();
// Engagement score distribution
$engagementDistribution = BuyerEngagementScore::forBusiness($business->id)->select(
DB::raw('CASE
WHEN score >= 80 THEN \'Very High\'
WHEN score >= 60 THEN \'High\'
WHEN score >= 40 THEN \'Medium\'
ELSE \'Low\'
END as score_range'),
DB::raw('COUNT(*) as count')
)
->groupBy('score_range')
->get();
return view('seller.analytics.dashboard', compact(
'business',
'period',
'metrics',
'trafficTrend',
'topProducts',
'highValueBuyers',
'recentIntentSignals',
'engagementDistribution'
));
}
}

View File

@@ -0,0 +1,194 @@
<?php
namespace App\Http\Controllers\Analytics;
use App\Http\Controllers\Controller;
use App\Models\Analytics\BuyerEngagementScore;
use App\Models\Analytics\IntentSignal;
use App\Models\Analytics\ProductView;
use App\Models\Business;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class BuyerIntelligenceController extends Controller
{
public function index(Request $request)
{
// TODO: Re-enable when permission system is implemented
// if (! hasBusinessPermission('analytics.buyers')) {
// abort(403, 'Unauthorized to view buyer intelligence');
// }
$business = currentBusiness();
$period = $request->input('period', '30');
$filter = $request->input('filter', 'all'); // all, high-value, at-risk, new
$startDate = now()->subDays((int) $period);
// Overall buyer metrics
$metrics = [
'total_buyers' => BuyerEngagementScore::forBusiness($business->id)->count(),
'active_buyers' => BuyerEngagementScore::forBusiness($business->id)->active()->count(),
'high_value_buyers' => BuyerEngagementScore::forBusiness($business->id)->highValue()->count(),
'at_risk_buyers' => BuyerEngagementScore::forBusiness($business->id)->atRisk()->count(),
'new_buyers' => BuyerEngagementScore::forBusiness($business->id)->where('first_interaction_at', '>=', now()->subDays(30))->count(),
];
// Build query based on filter
$buyersQuery = BuyerEngagementScore::forBusiness($business->id);
match ($filter) {
'high-value' => $buyersQuery->highValue(),
'at-risk' => $buyersQuery->atRisk(),
'new' => $buyersQuery->where('first_interaction_at', '>=', now()->subDays(30)),
default => $buyersQuery,
};
$buyers = $buyersQuery->orderByDesc('score')
->with('buyerBusiness')
->paginate(20);
// Engagement score distribution
$scoreDistribution = BuyerEngagementScore::forBusiness($business->id)->select(
DB::raw("CASE
WHEN score >= 80 THEN 'Very High (80-100)'
WHEN score >= 60 THEN 'High (60-79)'
WHEN score >= 40 THEN 'Medium (40-59)'
WHEN score >= 20 THEN 'Low (20-39)'
ELSE 'Very Low (0-19)'
END as score_range"),
DB::raw('COUNT(*) as count')
)
->groupBy('score_range')
->get();
// Tier distribution
$tierDistribution = BuyerEngagementScore::forBusiness($business->id)->select('score_tier')
->selectRaw('COUNT(*) as count')
->groupBy('score_tier')
->get();
// Recent high-intent signals
$recentIntentSignals = IntentSignal::forBusiness($business->id)->highIntent()
->where('detected_at', '>=', now()->subDays(7))
->orderByDesc('detected_at')
->with(['buyerBusiness', 'user'])
->limit(20)
->get();
// Intent signal breakdown
$signalBreakdown = IntentSignal::forBusiness($business->id)->where('detected_at', '>=', $startDate)
->select('signal_type')
->selectRaw('COUNT(*) as count')
->selectRaw('AVG(signal_strength) as avg_strength')
->groupBy('signal_type')
->orderByDesc('count')
->get();
return view('seller.analytics.buyers', compact(
'business',
'period',
'filter',
'metrics',
'buyers',
'scoreDistribution',
'tierDistribution',
'recentIntentSignals',
'signalBreakdown'
));
}
public function show(Request $request, Business $buyer)
{
// TODO: Re-enable when permission system is implemented
// if (! hasBusinessPermission('analytics.buyers')) {
// abort(403, 'Unauthorized to view buyer intelligence');
// }
$business = currentBusiness();
$period = $request->input('period', '90'); // Default to 90 days for buyer detail
$startDate = now()->subDays((int) $period);
// Get engagement score
$engagementScore = BuyerEngagementScore::forBusiness($business->id)->where('buyer_business_id', $buyer->id)->first();
// Activity timeline
$activityTimeline = ProductView::forBusiness($business->id)->where('buyer_business_id', $buyer->id)
->where('viewed_at', '>=', $startDate)
->select(
DB::raw('DATE(viewed_at) as date'),
DB::raw('COUNT(*) as product_views'),
DB::raw('COUNT(DISTINCT product_id) as unique_products'),
DB::raw('SUM(CASE WHEN added_to_cart = true THEN 1 ELSE 0 END) as cart_adds')
)
->groupBy('date')
->orderBy('date')
->get();
// Products viewed
$productsViewed = ProductView::forBusiness($business->id)->where('buyer_business_id', $buyer->id)
->where('viewed_at', '>=', $startDate)
->select('product_id')
->selectRaw('COUNT(*) as view_count')
->selectRaw('MAX(viewed_at) as last_viewed')
->selectRaw('AVG(time_on_page) as avg_time')
->selectRaw('SUM(CASE WHEN added_to_cart = true THEN 1 ELSE 0 END) as cart_adds')
->groupBy('product_id')
->orderByDesc('view_count')
->with('product')
->limit(20)
->get();
// Intent signals
$intentSignals = IntentSignal::forBusiness($business->id)->where('buyer_business_id', $buyer->id)
->where('detected_at', '>=', $startDate)
->orderByDesc('detected_at')
->limit(50)
->get();
// Email engagement
$emailEngagement = DB::table('email_interactions')
->join('users', 'email_interactions.recipient_user_id', '=', 'users.id')
->join('business_user', 'users.id', '=', 'business_user.user_id')
->where('email_interactions.business_id', $business->id)
->where('business_user.business_id', $buyer->id)
->where('email_interactions.sent_at', '>=', $startDate)
->selectRaw('COUNT(*) as total_sent')
->selectRaw('SUM(open_count) as total_opens')
->selectRaw('SUM(click_count) as total_clicks')
->selectRaw('AVG(engagement_score) as avg_engagement')
->first();
// Order history
$orderHistory = DB::table('orders')
->where('seller_business_id', $business->id)
->where('buyer_business_id', $buyer->id)
->select(
DB::raw('DATE(created_at) as date'),
DB::raw('COUNT(*) as order_count'),
DB::raw('SUM(total) as revenue')
)
->groupBy('date')
->orderBy('date')
->get();
$totalOrders = DB::table('orders')
->where('seller_business_id', $business->id)
->where('buyer_business_id', $buyer->id)
->selectRaw('COUNT(*) as count')
->selectRaw('SUM(total) as total_revenue')
->selectRaw('AVG(total) as avg_order_value')
->first();
return view('seller.analytics.buyer-detail', compact(
'buyer',
'period',
'engagementScore',
'activityTimeline',
'productsViewed',
'intentSignals',
'emailEngagement',
'orderHistory',
'totalOrders'
));
}
}

View File

@@ -0,0 +1,173 @@
<?php
namespace App\Http\Controllers\Analytics;
use App\Http\Controllers\Controller;
use App\Models\Analytics\EmailCampaign;
use App\Models\Analytics\EmailInteraction;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class MarketingAnalyticsController extends Controller
{
public function index(Request $request)
{
if (! hasBusinessPermission('analytics.marketing')) {
abort(403, 'Unauthorized to view marketing analytics');
}
$business = currentBusiness();
$period = $request->input('period', '30');
$startDate = now()->subDays((int) $period);
// Campaign overview metrics
$metrics = [
'total_campaigns' => EmailCampaign::forBusiness($business->id)->where('created_at', '>=', $startDate)->count(),
'total_sent' => EmailCampaign::forBusiness($business->id)->where('created_at', '>=', $startDate)->sum('total_sent'),
'total_delivered' => EmailCampaign::forBusiness($business->id)->where('created_at', '>=', $startDate)->sum('total_delivered'),
'total_opened' => EmailCampaign::forBusiness($business->id)->where('created_at', '>=', $startDate)->sum('total_opened'),
'total_clicked' => EmailCampaign::forBusiness($business->id)->where('created_at', '>=', $startDate)->sum('total_clicked'),
];
// Calculate average rates
$metrics['avg_open_rate'] = $metrics['total_delivered'] > 0
? round(($metrics['total_opened'] / $metrics['total_delivered']) * 100, 2)
: 0;
$metrics['avg_click_rate'] = $metrics['total_delivered'] > 0
? round(($metrics['total_clicked'] / $metrics['total_delivered']) * 100, 2)
: 0;
// Campaign performance
$campaigns = EmailCampaign::forBusiness($business->id)->where('created_at', '>=', $startDate)
->orderByDesc('sent_at')
->with('emailInteractions')
->paginate(20);
// Email engagement over time
$engagementTrend = EmailInteraction::forBusiness($business->id)->where('sent_at', '>=', $startDate)
->select(
DB::raw('DATE(sent_at) as date'),
DB::raw('COUNT(*) as sent'),
DB::raw('SUM(CASE WHEN first_opened_at IS NOT NULL THEN 1 ELSE 0 END) as opened'),
DB::raw('SUM(CASE WHEN first_clicked_at IS NOT NULL THEN 1 ELSE 0 END) as clicked')
)
->groupBy('date')
->orderBy('date')
->get();
// Top performing campaigns
$topCampaigns = EmailCampaign::forBusiness($business->id)->where('sent_at', '>=', $startDate)
->where('total_sent', '>', 0)
->orderByRaw('(total_clicked / total_sent) DESC')
->limit(10)
->get();
// Email client breakdown
$emailClients = EmailInteraction::forBusiness($business->id)->where('sent_at', '>=', $startDate)
->whereNotNull('email_client')
->select('email_client')
->selectRaw('COUNT(*) as count')
->groupBy('email_client')
->orderByDesc('count')
->get();
// Device type breakdown
$deviceTypes = EmailInteraction::forBusiness($business->id)->where('sent_at', '>=', $startDate)
->whereNotNull('device_type')
->select('device_type')
->selectRaw('COUNT(*) as count')
->groupBy('device_type')
->orderByDesc('count')
->get();
// Engagement score distribution
$engagementScores = EmailInteraction::forBusiness($business->id)->where('sent_at', '>=', $startDate)
->select(
DB::raw("CASE
WHEN engagement_score >= 80 THEN 'High'
WHEN engagement_score >= 50 THEN 'Medium'
WHEN engagement_score > 0 THEN 'Low'
ELSE 'None'
END as score_range"),
DB::raw('COUNT(*) as count')
)
->groupBy('score_range')
->get();
return view('seller.analytics.marketing', compact(
'business',
'period',
'metrics',
'campaigns',
'engagementTrend',
'topCampaigns',
'emailClients',
'deviceTypes',
'engagementScores'
));
}
public function campaign(Request $request, EmailCampaign $campaign)
{
if (! hasBusinessPermission('analytics.marketing')) {
abort(403, 'Unauthorized to view marketing analytics');
}
// Verify campaign belongs to user's business
if ($campaign->business_id !== currentBusinessId()) {
abort(403, 'Unauthorized to view this campaign');
}
// Campaign metrics
$metrics = [
'total_sent' => $campaign->total_sent,
'total_delivered' => $campaign->total_delivered,
'total_bounced' => $campaign->total_bounced,
'total_opened' => $campaign->total_opened,
'total_clicked' => $campaign->total_clicked,
'open_rate' => $campaign->open_rate,
'click_rate' => $campaign->click_rate,
'bounce_rate' => $campaign->total_sent > 0
? round(($campaign->total_bounced / $campaign->total_sent) * 100, 2)
: 0,
];
// Interaction timeline
$timeline = EmailInteraction::forBusiness($campaign->business_id)->where('campaign_id', $campaign->id)
->select(
DB::raw('DATE(sent_at) as date'),
DB::raw('SUM(open_count) as opens'),
DB::raw('SUM(click_count) as clicks')
)
->groupBy('date')
->orderBy('date')
->get();
// Top engaged recipients
$topRecipients = EmailInteraction::forBusiness($campaign->business_id)->where('campaign_id', $campaign->id)
->orderByDesc('engagement_score')
->limit(20)
->with('recipientUser')
->get();
// Click breakdown by URL
$clicksByUrl = DB::table('email_clicks')
->join('email_interactions', 'email_clicks.email_interaction_id', '=', 'email_interactions.id')
->where('email_interactions.campaign_id', $campaign->id)
->select('email_clicks.url', 'email_clicks.link_identifier')
->selectRaw('COUNT(*) as click_count')
->selectRaw('COUNT(DISTINCT email_clicks.email_interaction_id) as unique_clicks')
->groupBy('email_clicks.url', 'email_clicks.link_identifier')
->orderByDesc('click_count')
->get();
return view('seller.analytics.campaign-detail', compact(
'campaign',
'metrics',
'timeline',
'topRecipients',
'clicksByUrl'
));
}
}

View File

@@ -0,0 +1,164 @@
<?php
namespace App\Http\Controllers\Analytics;
use App\Helpers\BusinessHelper;
use App\Http\Controllers\Controller;
use App\Models\Analytics\ProductView;
use App\Models\Product;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class ProductAnalyticsController extends Controller
{
public function index(Request $request)
{
if (! hasBusinessPermission('analytics.products')) {
abort(403, 'Unauthorized to view product analytics');
}
$business = currentBusiness();
$period = $request->input('period', '30');
$startDate = now()->subDays((int) $period);
// Product performance metrics
$productMetrics = ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)
->select('product_id')
->selectRaw('COUNT(*) as total_views')
->selectRaw('COUNT(DISTINCT buyer_business_id) as unique_buyers')
->selectRaw('AVG(time_on_page) as avg_time_on_page')
->selectRaw('SUM(CASE WHEN zoomed_image = true THEN 1 ELSE 0 END) as zoomed_count')
->selectRaw('SUM(CASE WHEN watched_video = true THEN 1 ELSE 0 END) as video_views')
->selectRaw('SUM(CASE WHEN downloaded_spec = true THEN 1 ELSE 0 END) as spec_downloads')
->selectRaw('SUM(CASE WHEN added_to_cart = true THEN 1 ELSE 0 END) as cart_additions')
->groupBy('product_id')
->orderByDesc('total_views')
->with('product.brand')
->paginate(20);
// Product view trend
$viewTrend = ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)
->select(
DB::raw('DATE(viewed_at) as date'),
DB::raw('COUNT(*) as views'),
DB::raw('COUNT(DISTINCT buyer_business_id) as unique_buyers')
)
->groupBy('date')
->orderBy('date')
->get();
// High engagement products (quality over quantity)
$highEngagementProducts = ProductView::forBusiness($business->id)->highEngagement()
->where('viewed_at', '>=', $startDate)
->select('product_id')
->selectRaw('COUNT(*) as engagement_count')
->selectRaw('AVG(time_on_page) as avg_time')
->groupBy('product_id')
->orderByDesc('engagement_count')
->limit(10)
->with('product')
->get();
// Products with most cart additions (high intent)
$topCartProducts = ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)
->where('added_to_cart', true)
->select('product_id')
->selectRaw('COUNT(*) as cart_count')
->groupBy('product_id')
->orderByDesc('cart_count')
->limit(10)
->with('product')
->get();
// Engagement breakdown
$engagementBreakdown = [
'zoomed_image' => ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)->where('zoomed_image', true)->count(),
'watched_video' => ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)->where('watched_video', true)->count(),
'downloaded_spec' => ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)->where('downloaded_spec', true)->count(),
'added_to_cart' => ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)->where('added_to_cart', true)->count(),
'added_to_wishlist' => ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)->where('added_to_wishlist', true)->count(),
];
return view('seller.analytics.products', compact(
'business',
'period',
'productMetrics',
'viewTrend',
'highEngagementProducts',
'topCartProducts',
'engagementBreakdown'
));
}
public function show(Request $request, Product $product)
{
if (! hasBusinessPermission('analytics.products')) {
abort(403, 'Unauthorized to view product analytics');
}
// Verify product belongs to user's business brands
$sellerBusiness = BusinessHelper::fromProduct($product);
if ($sellerBusiness->id !== currentBusinessId()) {
abort(403, 'Unauthorized to view this product');
}
$period = $request->input('period', '30');
$startDate = now()->subDays((int) $period);
// Product-specific metrics
$metrics = ProductView::forBusiness($sellerBusiness->id)->where('product_id', $product->id)
->where('viewed_at', '>=', $startDate)
->selectRaw('COUNT(*) as total_views')
->selectRaw('COUNT(DISTINCT buyer_business_id) as unique_buyers')
->selectRaw('COUNT(DISTINCT session_id) as unique_sessions')
->selectRaw('AVG(time_on_page) as avg_time_on_page')
->selectRaw('MAX(time_on_page) as max_time_on_page')
->selectRaw('SUM(CASE WHEN zoomed_image = true THEN 1 ELSE 0 END) as zoomed_count')
->selectRaw('SUM(CASE WHEN watched_video = true THEN 1 ELSE 0 END) as video_views')
->selectRaw('SUM(CASE WHEN downloaded_spec = true THEN 1 ELSE 0 END) as spec_downloads')
->selectRaw('SUM(CASE WHEN added_to_cart = true THEN 1 ELSE 0 END) as cart_additions')
->first();
// View trend
$viewTrend = ProductView::forBusiness($sellerBusiness->id)->where('product_id', $product->id)
->where('viewed_at', '>=', $startDate)
->select(
DB::raw('DATE(viewed_at) as date'),
DB::raw('COUNT(*) as views')
)
->groupBy('date')
->orderBy('date')
->get();
// Top buyers viewing this product
$topBuyers = ProductView::forBusiness($sellerBusiness->id)->where('product_id', $product->id)
->where('viewed_at', '>=', $startDate)
->whereNotNull('buyer_business_id')
->select('buyer_business_id')
->selectRaw('COUNT(*) as view_count')
->selectRaw('MAX(viewed_at) as last_viewed')
->groupBy('buyer_business_id')
->orderByDesc('view_count')
->limit(10)
->with('buyerBusiness')
->get();
// Traffic sources
$trafficSources = ProductView::forBusiness($sellerBusiness->id)->where('product_id', $product->id)
->where('viewed_at', '>=', $startDate)
->select('source')
->selectRaw('COUNT(*) as count')
->groupBy('source')
->orderByDesc('count')
->get();
return view('seller.analytics.product-detail', compact(
'product',
'period',
'metrics',
'viewTrend',
'topBuyers',
'trafficSources'
));
}
}

View File

@@ -0,0 +1,160 @@
<?php
namespace App\Http\Controllers\Analytics;
use App\Http\Controllers\Controller;
use App\Models\Analytics\UserSession;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class SalesAnalyticsController extends Controller
{
public function index(Request $request)
{
if (! hasBusinessPermission('analytics.sales')) {
abort(403, 'Unauthorized to view sales analytics');
}
$business = currentBusiness();
$period = $request->input('period', '30');
$startDate = now()->subDays((int) $period);
// Sales funnel metrics
$funnelMetrics = [
'total_sessions' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)->count(),
'sessions_with_product_views' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)
->where('product_views', '>', 0)
->count(),
'sessions_with_cart' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)
->where('interactions', '>', 0)
->count(),
'checkout_initiated' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)
->where('interactions', '>', 2)
->count(),
'orders_completed' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)
->where('converted', true)
->count(),
];
// Calculate conversion rates
$funnelMetrics['product_view_rate'] = $funnelMetrics['total_sessions'] > 0
? round(($funnelMetrics['sessions_with_product_views'] / $funnelMetrics['total_sessions']) * 100, 2)
: 0;
$funnelMetrics['cart_rate'] = $funnelMetrics['sessions_with_product_views'] > 0
? round(($funnelMetrics['sessions_with_cart'] / $funnelMetrics['sessions_with_product_views']) * 100, 2)
: 0;
$funnelMetrics['checkout_rate'] = $funnelMetrics['sessions_with_cart'] > 0
? round(($funnelMetrics['checkout_initiated'] / $funnelMetrics['sessions_with_cart']) * 100, 2)
: 0;
$funnelMetrics['conversion_rate'] = $funnelMetrics['checkout_initiated'] > 0
? round(($funnelMetrics['orders_completed'] / $funnelMetrics['checkout_initiated']) * 100, 2)
: 0;
// Sales metrics from orders table
// Note: orders.business_id is the buyer's business
// To get seller's orders, join through order_items → products → brands
$salesMetrics = DB::table('orders')
->join('order_items', 'orders.id', '=', 'order_items.order_id')
->join('products', 'order_items.product_id', '=', 'products.id')
->join('brands', 'products.brand_id', '=', 'brands.id')
->where('brands.business_id', $business->id)
->where('orders.created_at', '>=', $startDate)
->selectRaw('COUNT(DISTINCT orders.id) as total_orders')
->selectRaw('SUM(order_items.line_total) as total_revenue')
->selectRaw('AVG(orders.total) as avg_order_value')
->selectRaw('COUNT(DISTINCT orders.business_id) as unique_buyers')
->first();
// Revenue trend
$revenueTrend = DB::table('orders')
->join('order_items', 'orders.id', '=', 'order_items.order_id')
->join('products', 'order_items.product_id', '=', 'products.id')
->join('brands', 'products.brand_id', '=', 'brands.id')
->where('brands.business_id', $business->id)
->where('orders.created_at', '>=', $startDate)
->select(
DB::raw('DATE(orders.created_at) as date'),
DB::raw('COUNT(DISTINCT orders.id) as orders'),
DB::raw('SUM(order_items.line_total) as revenue')
)
->groupBy('date')
->orderBy('date')
->get();
// Conversion funnel trend
$conversionTrend = UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)
->select(
DB::raw('DATE(started_at) as date'),
DB::raw('COUNT(*) as sessions'),
DB::raw('SUM(CASE WHEN product_views > 0 THEN 1 ELSE 0 END) as with_views'),
DB::raw('SUM(CASE WHEN interactions > 0 THEN 1 ELSE 0 END) as with_interactions'),
DB::raw('SUM(CASE WHEN converted = true THEN 1 ELSE 0 END) as conversions'),
DB::raw('SUM(CASE WHEN converted = true THEN 1 ELSE 0 END) as orders')
)
->groupBy('date')
->orderBy('date')
->get();
// Top revenue products
$topProducts = DB::table('order_items')
->join('orders', 'order_items.order_id', '=', 'orders.id')
->join('products', 'order_items.product_id', '=', 'products.id')
->join('brands', 'products.brand_id', '=', 'brands.id')
->where('brands.business_id', $business->id)
->where('orders.created_at', '>=', $startDate)
->select('products.id', 'products.name')
->selectRaw('SUM(order_items.quantity) as units_sold')
->selectRaw('SUM(order_items.line_total) as revenue')
->groupBy('products.id', 'products.name')
->orderByDesc('revenue')
->limit(10)
->get();
// Session abandonment analysis (sessions with interactions but no conversion)
$cartAbandonment = [
'total_interactive_sessions' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)
->where('interactions', '>', 0)
->count(),
'abandoned_sessions' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)
->where('interactions', '>', 0)
->where('converted', false)
->count(),
];
$cartAbandonment['abandonment_rate'] = $cartAbandonment['total_interactive_sessions'] > 0
? round(($cartAbandonment['abandoned_sessions'] / $cartAbandonment['total_interactive_sessions']) * 100, 2)
: 0;
// Top buyers by revenue
$topBuyers = DB::table('orders')
->join('order_items', 'orders.id', '=', 'order_items.order_id')
->join('products', 'order_items.product_id', '=', 'products.id')
->join('brands', 'products.brand_id', '=', 'brands.id')
->join('businesses', 'orders.business_id', '=', 'businesses.id')
->where('brands.business_id', $business->id)
->where('orders.created_at', '>=', $startDate)
->select('businesses.id', 'businesses.name')
->selectRaw('COUNT(DISTINCT orders.id) as order_count')
->selectRaw('SUM(order_items.line_total) as total_revenue')
->selectRaw('AVG(orders.total) as avg_order_value')
->groupBy('businesses.id', 'businesses.name')
->orderByDesc('total_revenue')
->limit(10)
->get();
return view('seller.analytics.sales', compact(
'business',
'period',
'funnelMetrics',
'salesMetrics',
'revenueTrend',
'conversionTrend',
'topProducts',
'cartAbandonment',
'topBuyers'
));
}
}

View File

@@ -0,0 +1,190 @@
<?php
namespace App\Http\Controllers\Analytics;
use App\Http\Controllers\Controller;
use App\Models\Product;
use App\Services\AnalyticsTracker;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class TrackingController extends Controller
{
protected AnalyticsTracker $tracker;
public function __construct(AnalyticsTracker $tracker)
{
$this->tracker = $tracker;
}
/**
* Initialize or update session
*/
public function session(Request $request)
{
try {
$session = $this->tracker->startSession();
return response()->json([
'success' => true,
'session_id' => $session->session_id,
]);
} catch (\Exception $e) {
Log::error('Analytics session tracking failed', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
return response()->json([
'success' => false,
'error' => 'Session tracking failed',
], 500);
}
}
/**
* Track various analytics events
*/
public function track(Request $request)
{
try {
$eventType = $request->input('event_type');
switch ($eventType) {
case 'page_view':
$this->trackPageView($request);
break;
case 'product_view':
$this->trackProductView($request);
break;
case 'page_engagement':
$this->trackPageEngagement($request);
break;
case 'click':
$this->trackClick($request);
break;
default:
$this->trackGenericEvent($request);
}
return response()->json(['success' => true]);
} catch (\Exception $e) {
Log::error('Analytics tracking failed', [
'event_type' => $request->input('event_type'),
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
return response()->json([
'success' => false,
'error' => 'Tracking failed',
], 500);
}
}
/**
* Track page view
*/
protected function trackPageView(Request $request): void
{
$this->tracker->updateSessionPageView();
$this->tracker->trackEvent(
'page_view',
'navigation',
'view',
null,
null,
[
'url' => $request->input('url'),
'title' => $request->input('title'),
'referrer' => $request->input('referrer'),
]
);
}
/**
* Track product view with engagement signals
*/
protected function trackProductView(Request $request): void
{
$productId = $request->input('product_id');
if (! $productId) {
return;
}
$product = Product::find($productId);
if (! $product) {
return;
}
$signals = [
'time_on_page' => $request->input('time_on_page'),
'scroll_depth' => $request->input('scroll_depth'),
'zoomed_image' => $request->boolean('zoomed_image'),
'watched_video' => $request->boolean('watched_video'),
'downloaded_spec' => $request->boolean('downloaded_spec'),
'added_to_cart' => $request->boolean('added_to_cart'),
'added_to_wishlist' => $request->boolean('added_to_wishlist'),
];
$this->tracker->trackProductView($product, $signals);
}
/**
* Track generic page engagement
*/
protected function trackPageEngagement(Request $request): void
{
$this->tracker->updateSessionPageView();
$this->tracker->trackEvent(
'page_engagement',
'engagement',
'interact',
null,
null,
[
'time_on_page' => $request->input('time_on_page'),
'scroll_depth' => $request->input('scroll_depth'),
]
);
}
/**
* Track click event
*/
protected function trackClick(Request $request): void
{
$this->tracker->trackClick(
$request->input('element_type', 'unknown'),
$request->input('element_id'),
$request->input('element_label'),
$request->input('url'),
[
'timestamp' => $request->input('timestamp'),
]
);
}
/**
* Track generic event
*/
protected function trackGenericEvent(Request $request): void
{
$this->tracker->trackEvent(
$request->input('event_type', 'custom'),
$request->input('category', 'general'),
$request->input('action', 'action'),
$request->input('subject_id'),
$request->input('subject_type'),
$request->input('metadata', [])
);
}
}

View File

@@ -35,10 +35,13 @@ class AnalyticsController extends Controller
'topCustomers' => $this->getTopCustomers($brandIds, 10),
];
return view('seller.analytics.index', [
$period = $request->input('period', '30');
return view('seller.analytics.dashboard', [
'user' => $user,
'business' => $business,
'analyticsData' => $data,
'period' => $period,
]);
}
@@ -191,4 +194,127 @@ class AnalyticsController extends Controller
return $topCustomers;
}
/**
* Display sales analytics
*/
public function sales(Request $request)
{
$user = $request->user();
$business = $user->primaryBusiness();
if (! $business) {
return redirect()->route('seller.setup');
}
$period = $request->input('period', '30');
return view('seller.analytics.sales', compact('user', 'business', 'period'));
}
/**
* Display buyer analytics
*/
public function buyers(Request $request)
{
$user = $request->user();
$business = $user->primaryBusiness();
if (! $business) {
return redirect()->route('seller.setup');
}
$period = $request->input('period', '30');
return view('seller.analytics.buyers', compact('user', 'business', 'period'));
}
/**
* Display product analytics
*/
public function products(Request $request)
{
$user = $request->user();
$business = $user->primaryBusiness();
if (! $business) {
return redirect()->route('seller.setup');
}
$period = $request->input('period', '30');
// Get filtered brand IDs for multi-tenancy
$brandIds = \App\Http\Controllers\Seller\BrandSwitcherController::getFilteredBrandIds();
// Get product IDs for the filtered brands
$productIds = \App\Models\Product::whereIn('brand_id', $brandIds)->pluck('id')->toArray();
// Get engagement breakdown - count of different engagement types
$engagementBreakdown = [
'zoomed_image' => \App\Models\Analytics\ProductView::whereIn('product_id', $productIds)
->where('zoomed_image', true)
->count(),
'watched_video' => \App\Models\Analytics\ProductView::whereIn('product_id', $productIds)
->where('watched_video', true)
->count(),
'downloaded_spec' => \App\Models\Analytics\ProductView::whereIn('product_id', $productIds)
->where('downloaded_spec', true)
->count(),
'added_to_cart' => \App\Models\Analytics\ProductView::whereIn('product_id', $productIds)
->where('added_to_cart', true)
->count(),
'added_to_wishlist' => \App\Models\Analytics\ProductView::whereIn('product_id', $productIds)
->where('added_to_wishlist', true)
->count(),
];
// Get product metrics - aggregate view data per product
$productMetrics = \App\Models\Analytics\ProductView::whereIn('product_id', $productIds)
->selectRaw('
product_id,
COUNT(*) as total_views,
COUNT(DISTINCT buyer_business_id) as unique_buyers,
AVG(time_on_page) as avg_time_on_page,
SUM(CASE WHEN added_to_cart = true THEN 1 ELSE 0 END) as cart_additions
')
->with('product.brand')
->groupBy('product_id')
->orderByDesc('total_views')
->limit(20)
->get();
return view('seller.analytics.products', compact('user', 'business', 'period', 'engagementBreakdown', 'productMetrics'));
}
/**
* Display detailed analytics for a specific product
*/
public function productDetail(Request $request, Product $product)
{
$user = $request->user();
$business = $user->primaryBusiness();
if (! $business) {
return redirect()->route('seller.setup');
}
return view('seller.analytics.product-detail', compact('user', 'business', 'product'));
}
/**
* Display marketing analytics
*/
public function marketing(Request $request)
{
$user = $request->user();
$business = $user->primaryBusiness();
if (! $business) {
return redirect()->route('seller.setup');
}
$period = $request->input('period', '30');
return view('seller.analytics.marketing', compact('user', 'business', 'period'));
}
}

View File

@@ -39,7 +39,7 @@ class AuthenticatedSessionController extends Controller
$request->session()->regenerate();
// Redirect to main dashboard (LeafLink-style simple route)
// Redirect to main dashboard
return redirect(dashboard_url());
}

View File

@@ -5,11 +5,17 @@ declare(strict_types=1);
namespace App\Http\Controllers\Business;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\PermissionService;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
class UserController extends Controller
{
public function __construct(
protected PermissionService $permissionService
) {}
/**
* Display users with access to the business.
*/
@@ -19,13 +25,16 @@ class UserController extends Controller
$business = $user->businesses()->first();
if (! $business) {
return redirect()->route($this->getRoutePrefix().'.dashboard')
->with('error', 'No business associated with your account.');
// If no business found, redirect to home
return redirect('/')->with('error', 'No business associated with your account.');
}
// Load users with their pivot data (contact_type, is_primary, permissions)
// Load users with their pivot data (contact_type, is_primary, permissions, role_template)
// Exclude the business owner from the list
$users = $business->users()
->withPivot('contact_type', 'is_primary', 'permissions')
->where('users.id', '!=', $business->owner_user_id)
->withPivot('contact_type', 'is_primary', 'permissions', 'role', 'role_template')
->with('roles')
->orderBy('is_primary', 'desc')
->orderBy('first_name')
->get();
@@ -33,6 +42,8 @@ class UserController extends Controller
return view('business.users.index', [
'business' => $business,
'users' => $users,
'roleTemplates' => $this->permissionService->getRoleTemplates(),
'permissionCategories' => $this->permissionService->getPermissionsByCategory(),
]);
}

View File

@@ -0,0 +1,185 @@
<?php
namespace App\Http\Controllers\Business;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\PermissionService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class UserPermissionsController extends Controller
{
public function __construct(
protected PermissionService $permissionService
) {}
/**
* Update user permissions via AJAX
*/
public function update(Request $request, string $businessSlug, int $userId)
{
try {
$business = currentBusiness();
if (! $business) {
return response()->json([
'success' => false,
'message' => 'Business not found',
], 404);
}
// Only owners and admins can manage permissions
if (auth()->user()->user_type !== 'admin' && $business->owner_user_id !== auth()->id()) {
return response()->json([
'success' => false,
'message' => 'You do not have permission to manage user permissions',
], 403);
}
$user = User::findOrFail($userId);
// Verify user belongs to this business
if (! $user->businesses()->where('businesses.id', $business->id)->exists()) {
return response()->json([
'success' => false,
'message' => 'User does not belong to this business',
], 404);
}
// Prevent owner from modifying their own permissions
if ($user->id === $business->owner_user_id) {
return response()->json([
'success' => false,
'message' => 'Cannot modify owner permissions',
], 403);
}
$validated = $request->validate([
'permissions' => 'array',
'permissions.*' => 'string',
'role_template' => 'nullable|string',
]);
$permissions = $validated['permissions'] ?? [];
$roleTemplate = $validated['role_template'] ?? null;
// Set permissions using PermissionService
$success = $this->permissionService->setPermissions(
user: $user,
permissions: $permissions,
business: $business,
roleTemplate: $roleTemplate,
reason: 'Updated by '.auth()->user()->name.' via permissions modal'
);
if ($success) {
Log::info('User permissions updated', [
'business_id' => $business->id,
'target_user_id' => $user->id,
'actor_user_id' => auth()->id(),
'permissions_count' => count($permissions),
'role_template' => $roleTemplate,
]);
return response()->json([
'success' => true,
'message' => 'Permissions updated successfully',
]);
}
return response()->json([
'success' => false,
'message' => 'Failed to update permissions',
], 500);
} catch (\Exception $e) {
Log::error('Error updating user permissions', [
'error' => $e->getMessage(),
'user_id' => $userId,
'business_slug' => $businessSlug,
]);
return response()->json([
'success' => false,
'message' => 'An error occurred while updating permissions',
], 500);
}
}
/**
* Apply a role template to a user
*/
public function applyTemplate(Request $request, string $businessSlug, int $userId)
{
try {
$business = currentBusiness();
if (! $business) {
return response()->json([
'success' => false,
'message' => 'Business not found',
], 404);
}
// Only owners and admins can manage permissions
if (auth()->user()->user_type !== 'admin' && $business->owner_user_id !== auth()->id()) {
return response()->json([
'success' => false,
'message' => 'You do not have permission to manage user permissions',
], 403);
}
$user = User::findOrFail($userId);
// Verify user belongs to this business
if (! $user->businesses()->where('businesses.id', $business->id)->exists()) {
return response()->json([
'success' => false,
'message' => 'User does not belong to this business',
], 404);
}
$validated = $request->validate([
'template_key' => 'required|string',
'merge' => 'boolean',
]);
$templateKey = $validated['template_key'];
$merge = $validated['merge'] ?? false;
// Apply role template
$permissions = $this->permissionService->applyRoleTemplate(
user: $user,
templateKey: $templateKey,
business: $business,
merge: $merge
);
if ($permissions === null) {
return response()->json([
'success' => false,
'message' => 'Role template not found',
], 404);
}
return response()->json([
'success' => true,
'message' => 'Role template applied successfully',
'permissions' => $permissions,
]);
} catch (\Exception $e) {
Log::error('Error applying role template', [
'error' => $e->getMessage(),
'user_id' => $userId,
'business_slug' => $businessSlug,
]);
return response()->json([
'success' => false,
'message' => 'An error occurred while applying role template',
], 500);
}
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers\Buyer;
use App\Http\Controllers\Controller;
use App\Models\Brand;
use App\Models\Business;
use Illuminate\Http\Request;
class BrandBrowseController extends Controller
{
/**
* Show brand menu for buyers to browse and order
* This is the main product browsing interface for buyers
*
* @return \Illuminate\View\View
*/
public function browse(Request $request, string $businessSlug, string $brandHashid)
{
// Manually resolve business and brand (cross-tenant access allowed)
// Buyers can browse ANY seller's brand menu
$business = Business::where('slug', $businessSlug)->firstOrFail();
$brand = Brand::where('hashid', $brandHashid)
->where('business_id', $business->id)
->where('is_active', true)
->firstOrFail();
// Load brand with business relationship
$brand->load('business');
// Get products organized by product line
$products = $brand->products()
->with(['strain', 'images', 'productLine'])
->where('is_active', true)
->orderBy('product_line_id')
->orderBy('name')
->get();
// Group products by product line
$productsByLine = $products->groupBy(function ($product) {
return $product->productLine ? $product->productLine->name : 'Other Products';
});
// Get other brands from same business
$otherBrands = $business
->brands()
->where('id', '!=', $brand->id)
->where('is_active', true)
->get();
// Mark this as buyer view
$isSeller = false;
return view('seller.brands.preview', compact(
'business',
'brand',
'products',
'productsByLine',
'otherBrands',
'isSeller'
));
}
}

View File

@@ -56,25 +56,27 @@ class DashboardController extends Controller
$previousStart = now()->subDays(60);
$previousEnd = now()->subDays(30);
// Get order IDs that have items matching our brands
$currentOrderIds = \App\Models\OrderItem::whereIn('brand_name', $brandNames)
->whereHas('order', fn ($q) => $q->whereBetween('created_at', [$currentStart, $currentEnd]))
->pluck('order_id')
->unique();
// Get order IDs and revenue in single optimized queries using joins
$currentStats = \App\Models\Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
->whereIn('order_items.brand_name', $brandNames)
->whereBetween('orders.created_at', [$currentStart, $currentEnd])
->selectRaw('COUNT(DISTINCT orders.id) as order_count, SUM(orders.total) as revenue')
->first();
$previousOrderIds = \App\Models\OrderItem::whereIn('brand_name', $brandNames)
->whereHas('order', fn ($q) => $q->whereBetween('created_at', [$previousStart, $previousEnd]))
->pluck('order_id')
->unique();
$previousStats = \App\Models\Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
->whereIn('order_items.brand_name', $brandNames)
->whereBetween('orders.created_at', [$previousStart, $previousEnd])
->selectRaw('COUNT(DISTINCT orders.id) as order_count, SUM(orders.total) as revenue')
->first();
// Revenue
$currentRevenue = \App\Models\Order::whereIn('id', $currentOrderIds)->sum('total') / 100;
$previousRevenue = \App\Models\Order::whereIn('id', $previousOrderIds)->sum('total') / 100;
$currentRevenue = ($currentStats->revenue ?? 0) / 100;
$previousRevenue = ($previousStats->revenue ?? 0) / 100;
$revenueChange = $previousRevenue > 0 ? (($currentRevenue - $previousRevenue) / $previousRevenue) * 100 : 0;
// Orders count
$currentOrders = $currentOrderIds->count();
$previousOrders = $previousOrderIds->count();
$currentOrders = $currentStats->order_count ?? 0;
$previousOrders = $previousStats->order_count ?? 0;
$ordersChange = $previousOrders > 0 ? (($currentOrders - $previousOrders) / $previousOrders) * 100 : 0;
// Products count (active products for selected brand(s))
@@ -188,16 +190,11 @@ class DashboardController extends Controller
$start = now()->sub($count, $unit)->startOfDay();
$end = now()->endOfDay();
// Get all order IDs for the period
$orderIds = \App\Models\OrderItem::whereIn('brand_name', $brandNames)
->whereHas('order', fn ($q) => $q->whereBetween('created_at', [$start, $end]))
->pluck('order_id')
->unique();
// Get orders with dates
$orders = \App\Models\Order::whereIn('id', $orderIds)
->whereBetween('created_at', [$start, $end])
->selectRaw('DATE(created_at) as date, SUM(total) as revenue')
// Optimized query using join instead of subquery
$orders = \App\Models\Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
->whereIn('order_items.brand_name', $brandNames)
->whereBetween('orders.created_at', [$start, $end])
->selectRaw('DATE(orders.created_at) as date, SUM(orders.total) as revenue')
->groupBy('date')
->orderBy('date', 'asc')
->get();

View File

@@ -0,0 +1,364 @@
<?php
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Models\Batch;
use App\Models\Business;
use App\Models\Product;
use App\Services\QrCodeService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class BatchController extends Controller
{
/**
* Display a listing of batches for the business
*/
public function index(Request $request, Business $business)
{
// Build query for batches
$query = Batch::where('business_id', $business->id)
->with(['product.brand', 'coaFiles'])
->orderBy('production_date', 'desc');
// Search filter
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('batch_number', 'LIKE', "%{$search}%")
->orWhere('test_id', 'LIKE', "%{$search}%")
->orWhere('lot_number', 'LIKE', "%{$search}%")
->orWhereHas('product', function ($productQuery) use ($search) {
$productQuery->where('name', 'LIKE', "%{$search}%");
});
});
}
$batches = $query->paginate(20)->withQueryString();
// Separate active and inactive batches
$activeBatches = $batches->filter(fn ($batch) => $batch->is_active);
$inactiveBatches = $batches->filter(fn ($batch) => ! $batch->is_active);
return view('seller.batches.index', compact('business', 'batches', 'activeBatches', 'inactiveBatches'));
}
/**
* Show the form for creating a new batch
*/
public function create(Request $request, Business $business)
{
// Get products owned by this business
$products = Product::whereHas('brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})->orderBy('name', 'asc')->get();
// For the new architecture, components are products (the view expects $components)
$components = $products;
// Get existing component batches that can be used as sources for homogenized batches
$componentBatches = Batch::where('business_id', $business->id)
->where('quantity_remaining', '>', 0)
->where('is_active', true)
->where('is_quarantined', false)
->with('component')
->orderBy('batch_number')
->get();
return view('seller.batches.create', compact('business', 'products', 'components', 'componentBatches'));
}
/**
* Store a newly created batch
*/
public function store(Request $request, Business $business)
{
// Determine max value based on unit (% vs mg/g, mg/ml, mg/unit)
$maxValue = $request->cannabinoid_unit === '%' ? 100 : 1000;
$validated = $request->validate([
'product_id' => 'required|exists:products,id',
'cannabinoid_unit' => 'required|string|in:%,MG/ML,MG/G,MG/UNIT',
'batch_number' => 'nullable|string|max:100|unique:batches,batch_number',
'production_date' => 'nullable|date',
'test_date' => 'nullable|date',
'test_id' => 'nullable|string|max:100',
'lot_number' => 'nullable|string|max:100',
'lab_name' => 'nullable|string|max:255',
'thc_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
'thca_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
'cbd_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
'cbda_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
'cbg_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
'cbn_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
'delta_9_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
'total_terps_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
'notes' => 'nullable|string',
'coa_files.*' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240', // 10MB max per file
]);
// Verify product belongs to this business
$product = Product::whereHas('brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})->findOrFail($validated['product_id']);
// Set business_id
$validated['business_id'] = $business->id;
$validated['is_active'] = true; // New batches are active by default
// Create batch (calculations happen in model boot method)
$batch = Batch::create($validated);
// Handle COA file uploads
if ($request->hasFile('coa_files')) {
foreach ($request->file('coa_files') as $index => $file) {
$storagePath = "businesses/{$business->uuid}/batches/{$batch->id}/coas";
$fileName = uniqid().'.'.$file->getClientOriginalExtension();
$filePath = $file->storeAs($storagePath, $fileName, 'public');
$batch->coaFiles()->create([
'file_name' => $file->getClientOriginalName(),
'file_path' => $filePath,
'file_type' => $file->getClientOriginalExtension(),
'file_size' => $file->getSize(),
'is_primary' => $index === 0,
'display_order' => $index,
]);
}
}
// Auto-generate QR code for the new batch (with brand logo if available)
$qrService = app(QrCodeService::class);
$qrService->generateWithLogo($batch);
return redirect()
->route('seller.business.batches.index', $business->slug)
->with('success', 'Batch created successfully.');
}
/**
* Show the form for editing the specified batch
*/
public function edit(Request $request, Business $business, Batch $batch)
{
// Verify batch belongs to this business
if ($batch->business_id !== $business->id) {
abort(403, 'Unauthorized');
}
// Get products owned by this business
$products = Product::whereHas('brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})->orderBy('name', 'asc')->get();
$batch->load('coaFiles');
return view('seller.batches.edit', compact('business', 'batch', 'products'));
}
/**
* Update the specified batch
*/
public function update(Request $request, Business $business, Batch $batch)
{
// Verify batch belongs to this business
if ($batch->business_id !== $business->id) {
abort(403, 'Unauthorized');
}
// Determine max value based on unit (% vs mg/g, mg/ml, mg/unit)
$maxValue = $request->cannabinoid_unit === '%' ? 100 : 1000;
$validated = $request->validate([
'product_id' => 'required|exists:products,id',
'cannabinoid_unit' => 'required|string|in:%,MG/ML,MG/G,MG/UNIT',
'batch_number' => 'nullable|string|max:100|unique:batches,batch_number,'.$batch->id,
'production_date' => 'nullable|date',
'test_date' => 'nullable|date',
'test_id' => 'nullable|string|max:100',
'lot_number' => 'nullable|string|max:100',
'lab_name' => 'nullable|string|max:255',
'thc_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
'thca_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
'cbd_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
'cbda_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
'cbg_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
'cbn_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
'delta_9_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
'total_terps_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
'notes' => 'nullable|string',
'coa_files.*' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240', // 10MB max per file
]);
// Verify product belongs to this business
$product = Product::whereHas('brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})->findOrFail($validated['product_id']);
// Update batch (calculations happen in model boot method)
$batch->update($validated);
// Handle new COA file uploads
if ($request->hasFile('coa_files')) {
$existingFilesCount = $batch->coaFiles()->count();
foreach ($request->file('coa_files') as $index => $file) {
$storagePath = "businesses/{$business->uuid}/batches/{$batch->id}/coas";
$fileName = uniqid().'.'.$file->getClientOriginalExtension();
$filePath = $file->storeAs($storagePath, $fileName, 'public');
$batch->coaFiles()->create([
'file_name' => $file->getClientOriginalName(),
'file_path' => $filePath,
'file_type' => $file->getClientOriginalExtension(),
'file_size' => $file->getSize(),
'is_primary' => $existingFilesCount === 0 && $index === 0,
'display_order' => $existingFilesCount + $index,
]);
}
}
return redirect()
->route('seller.business.batches.index', $business->slug)
->with('success', 'Batch updated successfully.');
}
/**
* Remove the specified batch
*/
public function destroy(Request $request, Business $business, Batch $batch)
{
// Verify batch belongs to this business
if ($batch->business_id !== $business->id) {
abort(403, 'Unauthorized');
}
// Delete associated COA files from storage
foreach ($batch->coaFiles as $coaFile) {
if (Storage::disk('public')->exists($coaFile->file_path)) {
Storage::disk('public')->delete($coaFile->file_path);
}
}
$batch->delete();
return redirect()
->route('seller.business.batches.index', $business->slug)
->with('success', 'Batch deleted successfully.');
}
/**
* Generate QR code for a batch
*/
public function generateQrCode(Request $request, Business $business, Batch $batch)
{
// Verify batch belongs to this business
if ($batch->business_id !== $business->id) {
abort(403, 'Unauthorized');
}
$qrService = app(QrCodeService::class);
$result = $qrService->generateWithLogo($batch);
// Refresh batch to get updated qr_code_path
$batch->refresh();
return response()->json([
'success' => $result['success'],
'message' => $result['message'],
'qr_code_url' => $batch->qr_code_path ? Storage::url($batch->qr_code_path) : null,
'download_url' => $batch->qr_code_path ? route('seller.business.batches.qr-code.download', [$business->slug, $batch->id]) : null,
]);
}
/**
* Download QR code for a batch
*/
public function downloadQrCode(Request $request, Business $business, Batch $batch)
{
// Verify batch belongs to this business
if ($batch->business_id !== $business->id) {
abort(403, 'Unauthorized');
}
$qrService = app(QrCodeService::class);
$download = $qrService->download($batch);
if (! $download) {
return back()->with('error', 'QR code not found');
}
return $download;
}
/**
* Regenerate QR code for a batch
*/
public function regenerateQrCode(Request $request, Business $business, Batch $batch)
{
// Verify batch belongs to this business
if ($batch->business_id !== $business->id) {
abort(403, 'Unauthorized');
}
$qrService = app(QrCodeService::class);
$result = $qrService->regenerate($batch);
// Refresh batch to get updated qr_code_path
$batch->refresh();
return response()->json([
'success' => $result['success'],
'message' => $result['message'],
'qr_code_url' => $batch->qr_code_path ? Storage::url($batch->qr_code_path) : null,
'download_url' => $batch->qr_code_path ? route('seller.business.batches.qr-code.download', [$business->slug, $batch->id]) : null,
]);
}
/**
* Delete QR code for a batch
*/
public function deleteQrCode(Request $request, Business $business, Batch $batch)
{
// Verify batch belongs to this business
if ($batch->business_id !== $business->id) {
abort(403, 'Unauthorized');
}
$qrService = app(QrCodeService::class);
$result = $qrService->delete($batch);
return response()->json([
'success' => $result['success'],
'message' => $result['message'],
]);
}
/**
* Bulk generate QR codes for multiple batches
*/
public function bulkGenerateQrCodes(Request $request, Business $business)
{
$validated = $request->validate([
'batch_ids' => 'required|array',
'batch_ids.*' => 'exists:batches,id',
]);
// Verify all batches belong to this business
$batches = Batch::whereIn('id', $validated['batch_ids'])
->where('business_id', $business->id)
->get();
if ($batches->count() !== count($validated['batch_ids'])) {
return response()->json([
'success' => false,
'message' => 'Some batches do not belong to this business',
], 403);
}
$qrService = app(QrCodeService::class);
$result = $qrService->bulkGenerate($validated['batch_ids']);
return response()->json($result);
}
}

View File

@@ -0,0 +1,262 @@
<?php
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Models\Brand;
use App\Models\Business;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class BrandController extends Controller
{
/**
* Display a listing of brands for the business
*/
public function index(Request $request, Business $business)
{
$brands = $business->brands()
->orderBy('sort_order')
->orderBy('name')
->get();
return view('seller.brands.index', compact('business', 'brands'));
}
/**
* Show the form for creating a new brand
*/
public function create(Business $business)
{
return view('seller.brands.create', compact('business'));
}
/**
* Store a newly created brand in storage
*/
public function store(Request $request, Business $business)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'tagline' => 'nullable|string|max:45',
'description' => 'nullable|string|max:300',
'long_description' => 'nullable|string|max:1000',
'website_url' => 'nullable|string|max:255',
'address' => 'nullable|string|max:255',
'unit_number' => 'nullable|string|max:50',
'city' => 'nullable|string|max:100',
'state' => 'nullable|string|max:2',
'zip_code' => 'nullable|string|max:10',
'phone' => 'nullable|string|max:20',
'logo' => 'nullable|image|max:2048',
'banner' => 'nullable|image|max:4096',
'is_public' => 'boolean',
'is_featured' => 'boolean',
'is_active' => 'boolean',
'instagram_handle' => 'nullable|string|max:255',
'facebook_url' => 'nullable|url|max:255',
'twitter_handle' => 'nullable|string|max:255',
'youtube_url' => 'nullable|url|max:255',
]);
// Automatically add https:// to website_url if not present
if ($request->filled('website_url')) {
$url = $validated['website_url'];
if (! str_starts_with($url, 'http://') && ! str_starts_with($url, 'https://')) {
$validated['website_url'] = 'https://'.$url;
}
}
// Generate slug from name
$validated['slug'] = Str::slug($validated['name']);
// Handle logo upload
if ($request->hasFile('logo')) {
$validated['logo_path'] = $request->file('logo')->store('brands/logos', 'public');
}
// Handle banner upload
if ($request->hasFile('banner')) {
$validated['banner_path'] = $request->file('banner')->store('brands/banners', 'public');
}
// Set boolean defaults
$validated['is_public'] = $request->boolean('is_public');
$validated['is_featured'] = $request->boolean('is_featured');
$validated['is_active'] = $request->boolean('is_active');
// Create brand
$brand = $business->brands()->create($validated);
return redirect()
->route('seller.business.settings.brands', $business->slug)
->with('success', 'Brand created successfully!');
}
/**
* Display the specified brand (read-only view)
*/
public function show(Business $business, Brand $brand)
{
// Ensure brand belongs to this business
if ($brand->business_id !== $business->id) {
abort(403, 'This brand does not belong to your business.');
}
// Load relationships
$brand->load(['business', 'products']);
return view('seller.brands.show', compact('business', 'brand'));
}
/**
* Show the form for editing the specified brand
*/
public function edit(Business $business, Brand $brand)
{
// Ensure brand belongs to this business
if ($brand->business_id !== $business->id) {
abort(403, 'This brand does not belong to your business.');
}
return view('seller.brands.edit', compact('business', 'brand'));
}
/**
* Update the specified brand in storage
*/
public function update(Request $request, Business $business, Brand $brand)
{
// Ensure brand belongs to this business
if ($brand->business_id !== $business->id) {
abort(403, 'This brand does not belong to your business.');
}
$validated = $request->validate([
'name' => 'required|string|max:255',
'tagline' => 'nullable|string|max:45',
'description' => 'nullable|string|max:300',
'long_description' => 'nullable|string|max:1000',
'website_url' => 'nullable|string|max:255',
'address' => 'nullable|string|max:255',
'unit_number' => 'nullable|string|max:50',
'city' => 'nullable|string|max:100',
'state' => 'nullable|string|max:2',
'zip_code' => 'nullable|string|max:10',
'phone' => 'nullable|string|max:20',
'logo' => 'nullable|image|max:2048',
'banner' => 'nullable|image|max:4096',
'remove_logo' => 'boolean',
'remove_banner' => 'boolean',
'is_public' => 'boolean',
'is_featured' => 'boolean',
'is_active' => 'boolean',
'instagram_handle' => 'nullable|string|max:255',
'facebook_url' => 'nullable|url|max:255',
'twitter_handle' => 'nullable|string|max:255',
'youtube_url' => 'nullable|url|max:255',
]);
// Automatically add https:// to website_url if not present
if ($request->filled('website_url')) {
$url = $validated['website_url'];
if (! str_starts_with($url, 'http://') && ! str_starts_with($url, 'https://')) {
$validated['website_url'] = 'https://'.$url;
}
} else {
$validated['website_url'] = null;
}
// Update slug if name changed
if ($validated['name'] !== $brand->name) {
$validated['slug'] = Str::slug($validated['name']);
}
// Handle logo removal
if ($request->boolean('remove_logo') && $brand->logo_path) {
Storage::disk('public')->delete($brand->logo_path);
$validated['logo_path'] = null;
}
// Handle logo upload
if ($request->hasFile('logo')) {
// Delete old logo
if ($brand->logo_path) {
Storage::disk('public')->delete($brand->logo_path);
}
$validated['logo_path'] = $request->file('logo')->store('brands/logos', 'public');
}
// Handle banner removal
if ($request->boolean('remove_banner') && $brand->banner_path) {
Storage::disk('public')->delete($brand->banner_path);
$validated['banner_path'] = null;
}
// Handle banner upload
if ($request->hasFile('banner')) {
// Delete old banner
if ($brand->banner_path) {
Storage::disk('public')->delete($brand->banner_path);
}
$validated['banner_path'] = $request->file('banner')->store('brands/banners', 'public');
}
// Set boolean defaults
$validated['is_public'] = $request->boolean('is_public');
$validated['is_featured'] = $request->boolean('is_featured');
$validated['is_active'] = $request->boolean('is_active');
// Remove form-only fields
unset($validated['remove_logo'], $validated['remove_banner']);
// Update brand
$brand->update($validated);
return redirect()
->route('seller.business.settings.brands', $business->slug)
->with('success', 'Brand updated successfully!');
}
/**
* Remove the specified brand from storage
*/
public function destroy(Business $business, Brand $brand)
{
// Ensure brand belongs to this business
if ($brand->business_id !== $business->id) {
abort(403, 'This brand does not belong to your business.');
}
// Check user has permission (only company-owner or company-manager can delete)
if (! auth()->user()->hasAnyRole(['company-owner', 'company-manager'])) {
abort(403, 'You do not have permission to delete brands.');
}
// Check if brand has any products with sales/orders
$hasProductsWithSales = $brand->products()
->whereHas('orderItems')
->exists();
if ($hasProductsWithSales) {
return redirect()
->route('seller.business.settings.brands', $business->slug)
->with('error', 'Cannot delete brand - it has products with sales activity.');
}
// Delete logo and banner files
if ($brand->logo_path) {
Storage::disk('public')->delete($brand->logo_path);
}
if ($brand->banner_path) {
Storage::disk('public')->delete($brand->banner_path);
}
$brand->delete();
return redirect()
->route('seller.business.settings.brands', $business->slug)
->with('success', 'Brand deleted successfully!');
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Models\Brand;
use App\Models\Business;
use Illuminate\Http\Request;
class BrandPreviewController extends Controller
{
/**
* Show brand menu preview for sellers
* This allows sellers to preview how buyers will see their brand menu
*
* @return \Illuminate\View\View
*/
public function preview(Request $request, Business $business, Brand $brand)
{
// Verify the brand belongs to the business (business isolation)
if ($brand->business_id !== $business->id) {
abort(404, 'Brand not found for this business');
}
// Load brand with business relationship
$brand->load('business');
// Get products organized by product line
$products = $brand->products()
->with(['strain', 'images', 'productLine'])
->where('is_active', true)
->orderBy('product_line_id')
->orderBy('name')
->get();
// Group products by product line
$productsByLine = $products->groupBy(function ($product) {
return $product->productLine ? $product->productLine->name : 'Other Products';
});
// Get other brands from same business
$otherBrands = $business
->brands()
->where('id', '!=', $brand->id)
->where('is_active', true)
->get();
// Mark this as seller view
$isSeller = true;
return view('seller.brands.preview', compact(
'business',
'brand',
'products',
'productsByLine',
'otherBrands',
'isSeller'
));
}
}

View File

@@ -4,44 +4,34 @@ namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Models\Brand;
use App\Models\Business;
use Illuminate\Http\Request;
class BrandSwitcherController extends Controller
{
/**
* Switch the active brand context for the current session
* Switch the active business context for the current session
*/
public function switch(Request $request)
{
$brandId = $request->input('brand_id');
$businessId = $request->input('business_id');
// If brand_id is empty, clear the session (show all brands)
if (empty($brandId)) {
session()->forget('selected_brand_id');
return back();
if (empty($businessId)) {
return back()->with('error', 'No business selected');
}
// Verify the brand exists and belongs to user's business
// Verify the business exists and user has access
$user = auth()->user();
$business = $user->primaryBusiness();
$business = $user->businesses()->where('businesses.id', $businessId)->first();
if (! $business) {
return back()->with('error', 'No business associated with your account');
return back()->with('error', 'Business not found or you do not have access');
}
$brand = Brand::forBusiness($business)
->where('id', $brandId)
->first();
// Store selected business in session
session(['current_business_id' => $business->id]);
if (! $brand) {
return back()->with('error', 'Brand not found or you do not have access');
}
// Store selected brand in session
session(['selected_brand_id' => $brand->id]);
return back();
return back()->with('success', 'Switched to '.$business->name);
}
/**

View File

@@ -0,0 +1,224 @@
<?php
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\ComponentCategory;
use App\Models\ProductCategory;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class CategoryController extends Controller
{
public function index(Business $business)
{
// Load product categories with nesting and counts
$productCategories = ProductCategory::where('business_id', $business->id)
->whereNull('parent_id')
->with(['children' => function ($query) {
$query->orderBy('sort_order')->orderBy('name');
}])
->withCount('products')
->orderBy('sort_order')
->orderBy('name')
->get();
// Load component categories with nesting and counts
$componentCategories = ComponentCategory::where('business_id', $business->id)
->whereNull('parent_id')
->with(['children' => function ($query) {
$query->orderBy('sort_order')->orderBy('name');
}])
->withCount('components')
->orderBy('sort_order')
->orderBy('name')
->get();
return view('seller.settings.categories.index', compact('business', 'productCategories', 'componentCategories'));
}
public function create(Business $business, string $type)
{
// Validate type
if (! in_array($type, ['product', 'component'])) {
abort(404);
}
// Get all categories of this type for parent selection
$categories = $type === 'product'
? ProductCategory::where('business_id', $business->id)
->whereNull('parent_id')
->with('children')
->orderBy('name')
->get()
: ComponentCategory::where('business_id', $business->id)
->whereNull('parent_id')
->with('children')
->orderBy('name')
->get();
return view('seller.settings.categories.create', compact('business', 'type', 'categories'));
}
public function store(Request $request, Business $business, string $type)
{
// Validate type
if (! in_array($type, ['product', 'component'])) {
abort(404);
}
$tableName = $type === 'product' ? 'product_categories' : 'component_categories';
$validated = $request->validate([
'name' => 'required|string|max:255',
'parent_id' => "nullable|exists:{$tableName},id",
'description' => 'nullable|string',
'sort_order' => 'nullable|integer|min:0',
'is_active' => 'boolean',
'image' => 'nullable|image|max:2048',
]);
$validated['business_id'] = $business->id;
$validated['slug'] = Str::slug($validated['name']);
$validated['is_active'] = $request->has('is_active') ? true : false;
// Handle image upload
if ($request->hasFile('image')) {
$validated['image_path'] = $request->file('image')->store('categories', 'public');
}
// Validate parent belongs to same business if provided
if (! empty($validated['parent_id'])) {
$model = $type === 'product' ? ProductCategory::class : ComponentCategory::class;
$parent = $model::where('business_id', $business->id)->find($validated['parent_id']);
if (! $parent) {
return back()->withErrors(['parent_id' => 'Invalid parent category'])->withInput();
}
}
$model = $type === 'product' ? ProductCategory::class : ComponentCategory::class;
$model::create($validated);
return redirect()->route('seller.business.settings.categories.index', $business->slug)
->with('success', ucfirst($type).' category created successfully');
}
public function edit(Business $business, string $type, int $id)
{
// Validate type
if (! in_array($type, ['product', 'component'])) {
abort(404);
}
$model = $type === 'product' ? ProductCategory::class : ComponentCategory::class;
$category = $model::where('business_id', $business->id)->findOrFail($id);
// Get all categories of this type for parent selection (excluding self and descendants)
$categories = $model::where('business_id', $business->id)
->whereNull('parent_id')
->where('id', '!=', $id)
->with('children')
->orderBy('name')
->get();
return view('seller.settings.categories.edit', compact('business', 'type', 'category', 'categories'));
}
public function update(Request $request, Business $business, string $type, int $id)
{
// Validate type
if (! in_array($type, ['product', 'component'])) {
abort(404);
}
$model = $type === 'product' ? ProductCategory::class : ComponentCategory::class;
$category = $model::where('business_id', $business->id)->findOrFail($id);
$tableName = $type === 'product' ? 'product_categories' : 'component_categories';
$validated = $request->validate([
'name' => 'required|string|max:255',
'parent_id' => "nullable|exists:{$tableName},id",
'description' => 'nullable|string',
'sort_order' => 'nullable|integer|min:0',
'is_active' => 'boolean',
'image' => 'nullable|image|max:2048',
]);
$validated['slug'] = Str::slug($validated['name']);
$validated['is_active'] = $request->has('is_active') ? true : false;
// Handle image upload
if ($request->hasFile('image')) {
// Delete old image if exists
if ($category->image_path) {
\Storage::disk('public')->delete($category->image_path);
}
$validated['image_path'] = $request->file('image')->store('categories', 'public');
}
// Validate parent (can't be self or descendant)
if (! empty($validated['parent_id'])) {
if ($validated['parent_id'] == $id) {
return back()->withErrors(['parent_id' => 'Category cannot be its own parent'])->withInput();
}
$parent = $model::where('business_id', $business->id)->find($validated['parent_id']);
if (! $parent) {
return back()->withErrors(['parent_id' => 'Invalid parent category'])->withInput();
}
// Check for circular reference (if parent's parent is this category)
if ($parent->parent_id == $id) {
return back()->withErrors(['parent_id' => 'This would create a circular reference'])->withInput();
}
}
$category->update($validated);
return redirect()->route('seller.business.settings.categories.index', $business->slug)
->with('success', ucfirst($type).' category updated successfully');
}
public function destroy(Business $business, string $type, int $id)
{
// Validate type
if (! in_array($type, ['product', 'component'])) {
abort(404);
}
$model = $type === 'product' ? ProductCategory::class : ComponentCategory::class;
$category = $model::where('business_id', $business->id)->findOrFail($id);
// Check if has products/components
if ($type === 'product') {
$count = $category->products()->count();
if ($count > 0) {
return back()->with('error', "Cannot delete category with {$count} products. Please reassign or delete products first.");
}
} else {
$count = $category->components()->count();
if ($count > 0) {
return back()->with('error', "Cannot delete category with {$count} components. Please reassign or delete components first.");
}
}
// Check if has children
$childCount = $category->children()->count();
if ($childCount > 0) {
return back()->with('error', "Cannot delete category with {$childCount} subcategories. Please delete or move subcategories first.");
}
// Delete image if exists
if ($category->image_path) {
\Storage::disk('public')->delete($category->image_path);
}
$category->delete();
return redirect()->route('seller.business.settings.categories.index', $business->slug)
->with('success', ucfirst($type).' category deleted successfully');
}
}

View File

@@ -0,0 +1,100 @@
<?php
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\ComponentCategory;
use Illuminate\Http\Request;
class ComponentCategoryController extends Controller
{
/**
* Display a listing of component categories
*/
public function index(Request $request, Business $business)
{
$query = ComponentCategory::where('business_id', $business->id);
// Search filter
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('name', 'LIKE', "%{$search}%")
->orWhere('description', 'LIKE', "%{$search}%");
});
}
// Sort
$sortBy = $request->get('sort_by', 'name');
$sortDir = $request->get('sort_dir', 'asc');
$query->orderBy($sortBy, $sortDir);
$categories = $query->with('components')->paginate(20)->withQueryString();
return view('seller.categories.components.index', compact('business', 'categories'));
}
/**
* Store a newly created component category
*/
public function store(Request $request, Business $business)
{
$validated = $request->validate([
'name' => 'required|string|max:255|unique:component_categories,name,NULL,id,business_id,'.$business->id,
'description' => 'nullable|string',
'is_active' => 'boolean',
]);
$validated['business_id'] = $business->id;
$validated['is_active'] = $request->has('is_active');
$category = ComponentCategory::create($validated);
return back()->with('success', "Component category '{$category->name}' created successfully!");
}
/**
* Update the specified component category
*/
public function update(Request $request, Business $business, ComponentCategory $componentCategory)
{
// Verify category belongs to this business
if ($componentCategory->business_id !== $business->id) {
abort(403);
}
$validated = $request->validate([
'name' => 'required|string|max:255|unique:component_categories,name,'.$componentCategory->id.',id,business_id,'.$business->id,
'description' => 'nullable|string',
'is_active' => 'boolean',
]);
$validated['is_active'] = $request->has('is_active');
$componentCategory->update($validated);
return back()->with('success', "Component category '{$componentCategory->name}' updated successfully!");
}
/**
* Remove the specified component category
*/
public function destroy(Business $business, ComponentCategory $componentCategory)
{
// Verify category belongs to this business
if ($componentCategory->business_id !== $business->id) {
abort(403);
}
// Check if category is in use
$componentsCount = $componentCategory->components()->count();
if ($componentsCount > 0) {
return back()->with('error', "Cannot delete category '{$componentCategory->name}' because it has {$componentsCount} associated components.");
}
$name = $componentCategory->name;
$componentCategory->delete();
return back()->with('success', "Component category '{$name}' deleted successfully!");
}
}

View File

@@ -0,0 +1,454 @@
<?php
namespace App\Http\Controllers\Seller\Marketing;
use App\Http\Controllers\Controller;
use App\Models\Broadcast;
use App\Models\MarketingAudience;
use App\Models\MarketingTemplate;
use App\Services\Marketing\BroadcastService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class BroadcastController extends Controller
{
protected BroadcastService $broadcastService;
public function __construct(BroadcastService $broadcastService)
{
$this->broadcastService = $broadcastService;
}
/**
* Display list of broadcasts
*/
public function index(Request $request)
{
$business = $request->user()->currentBusiness;
$query = Broadcast::where('business_id', $business->id)
->with('createdBy', 'template');
// Filter by status
if ($request->has('status') && $request->status) {
$query->where('status', $request->status);
}
// Filter by channel
if ($request->has('channel') && $request->channel) {
$query->where('channel', $request->channel);
}
// Search
if ($request->has('search') && $request->search) {
$query->where(function ($q) use ($request) {
$q->where('name', 'LIKE', "%{$request->search}%")
->orWhere('description', 'LIKE', "%{$request->search}%");
});
}
$broadcasts = $query->orderBy('created_at', 'desc')->paginate(20);
return view('seller.marketing.broadcasts.index', compact('broadcasts'));
}
/**
* Show create form
*/
public function create(Request $request)
{
$business = $request->user()->currentBusiness;
$audiences = MarketingAudience::where('business_id', $business->id)
->orderBy('name')
->get();
$templates = MarketingTemplate::where('business_id', $business->id)
->orderBy('name')
->get();
return view('seller.marketing.broadcasts.create', compact('audiences', 'templates'));
}
/**
* Store new broadcast
*/
public function store(Request $request)
{
$business = $request->user()->currentBusiness;
$validated = $request->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string|max:1000',
'type' => 'required|in:immediate,scheduled',
'channel' => 'required|in:email,sms,push,multi',
'template_id' => 'nullable|exists:marketing_templates,id',
'subject' => 'required_if:channel,email|nullable|string|max:255',
'content' => 'required_without:template_id|nullable|string',
'audience_ids' => 'nullable|array',
'audience_ids.*' => 'exists:marketing_audiences,id',
'include_all' => 'boolean',
'exclude_audience_ids' => 'nullable|array',
'scheduled_at' => 'required_if:type,scheduled|nullable|date|after:now',
'timezone' => 'nullable|string',
'track_opens' => 'boolean',
'track_clicks' => 'boolean',
'send_rate_limit' => 'nullable|integer|min:1|max:1000',
]);
$broadcast = Broadcast::create([
'business_id' => $business->id,
'created_by_user_id' => $request->user()->id,
...$validated,
'status' => 'draft',
]);
// Prepare recipients
try {
$count = $this->broadcastService->prepareBroadcast($broadcast);
return redirect()
->route('seller.marketing.broadcasts.show', $broadcast)
->with('success', "Broadcast created with {$count} recipients");
} catch (\Exception $e) {
return back()
->withInput()
->withErrors(['error' => 'Failed to prepare broadcast: '.$e->getMessage()]);
}
}
/**
* Show specific broadcast
*/
public function show(Request $request, Broadcast $broadcast)
{
$business = $request->user()->currentBusiness;
if ($broadcast->business_id !== $business->id) {
abort(403);
}
$broadcast->load(['createdBy', 'template', 'recipients' => function ($query) {
$query->with('user')->latest()->limit(50);
}]);
$stats = $this->broadcastService->getStatistics($broadcast);
// Get event timeline (recent events)
$recentEvents = $broadcast->events()
->with('user')
->latest('occurred_at')
->limit(20)
->get();
return view('seller.marketing.broadcasts.show', compact('broadcast', 'stats', 'recentEvents'));
}
/**
* Show edit form
*/
public function edit(Request $request, Broadcast $broadcast)
{
$business = $request->user()->currentBusiness;
if ($broadcast->business_id !== $business->id) {
abort(403);
}
if (! $broadcast->isDraft()) {
return back()->with('error', 'Only draft broadcasts can be edited');
}
$audiences = MarketingAudience::where('business_id', $business->id)
->orderBy('name')
->get();
$templates = MarketingTemplate::where('business_id', $business->id)
->orderBy('name')
->get();
return view('seller.marketing.broadcasts.edit', compact('broadcast', 'audiences', 'templates'));
}
/**
* Update broadcast
*/
public function update(Request $request, Broadcast $broadcast)
{
$business = $request->user()->currentBusiness;
if ($broadcast->business_id !== $business->id) {
abort(403);
}
if (! $broadcast->isDraft()) {
return back()->with('error', 'Only draft broadcasts can be updated');
}
$validated = $request->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string|max:1000',
'type' => 'required|in:immediate,scheduled',
'channel' => 'required|in:email,sms,push,multi',
'template_id' => 'nullable|exists:marketing_templates,id',
'subject' => 'required_if:channel,email|nullable|string|max:255',
'content' => 'required_without:template_id|nullable|string',
'audience_ids' => 'nullable|array',
'include_all' => 'boolean',
'exclude_audience_ids' => 'nullable|array',
'scheduled_at' => 'required_if:type,scheduled|nullable|date|after:now',
'track_opens' => 'boolean',
'track_clicks' => 'boolean',
'send_rate_limit' => 'nullable|integer|min:1|max:1000',
]);
$broadcast->update($validated);
// Re-prepare recipients
try {
$count = $this->broadcastService->prepareBroadcast($broadcast);
return redirect()
->route('seller.marketing.broadcasts.show', $broadcast)
->with('success', "Broadcast updated with {$count} recipients");
} catch (\Exception $e) {
return back()->with('error', 'Failed to update broadcast: '.$e->getMessage());
}
}
/**
* Delete broadcast
*/
public function destroy(Request $request, Broadcast $broadcast)
{
$business = $request->user()->currentBusiness;
if ($broadcast->business_id !== $business->id) {
abort(403);
}
if (! in_array($broadcast->status, ['draft', 'cancelled', 'failed'])) {
return back()->with('error', 'Cannot delete broadcast in current status');
}
$broadcast->delete();
return redirect()
->route('seller.marketing.broadcasts.index')
->with('success', 'Broadcast deleted');
}
/**
* Send broadcast
*/
public function send(Request $request, Broadcast $broadcast)
{
$business = $request->user()->currentBusiness;
if ($broadcast->business_id !== $business->id) {
abort(403);
}
try {
$this->broadcastService->sendBroadcast($broadcast);
return back()->with('success', 'Broadcast is now being sent');
} catch (\Exception $e) {
return back()->with('error', $e->getMessage());
}
}
/**
* Pause broadcast
*/
public function pause(Request $request, Broadcast $broadcast)
{
$business = $request->user()->currentBusiness;
if ($broadcast->business_id !== $business->id) {
abort(403);
}
try {
$this->broadcastService->pauseBroadcast($broadcast);
return back()->with('success', 'Broadcast paused');
} catch (\Exception $e) {
return back()->with('error', $e->getMessage());
}
}
/**
* Resume broadcast
*/
public function resume(Request $request, Broadcast $broadcast)
{
$business = $request->user()->currentBusiness;
if ($broadcast->business_id !== $business->id) {
abort(403);
}
try {
$this->broadcastService->resumeBroadcast($broadcast);
return back()->with('success', 'Broadcast resumed');
} catch (\Exception $e) {
return back()->with('error', $e->getMessage());
}
}
/**
* Cancel broadcast
*/
public function cancel(Request $request, Broadcast $broadcast)
{
$business = $request->user()->currentBusiness;
if ($broadcast->business_id !== $business->id) {
abort(403);
}
try {
$this->broadcastService->cancelBroadcast($broadcast);
return back()->with('success', 'Broadcast cancelled');
} catch (\Exception $e) {
return back()->with('error', $e->getMessage());
}
}
/**
* Duplicate broadcast
*/
public function duplicate(Request $request, Broadcast $broadcast)
{
$business = $request->user()->currentBusiness;
if ($broadcast->business_id !== $business->id) {
abort(403);
}
$newBroadcast = $broadcast->replicate();
$newBroadcast->name = $broadcast->name.' (Copy)';
$newBroadcast->status = 'draft';
$newBroadcast->created_by_user_id = $request->user()->id;
$newBroadcast->total_recipients = 0;
$newBroadcast->total_sent = 0;
$newBroadcast->total_delivered = 0;
$newBroadcast->total_failed = 0;
$newBroadcast->total_opened = 0;
$newBroadcast->total_clicked = 0;
$newBroadcast->started_sending_at = null;
$newBroadcast->finished_sending_at = null;
$newBroadcast->save();
// Prepare recipients
$this->broadcastService->prepareBroadcast($newBroadcast);
return redirect()
->route('seller.marketing.broadcasts.show', $newBroadcast)
->with('success', 'Broadcast duplicated');
}
/**
* Get progress (AJAX)
*/
public function progress(Request $request, Broadcast $broadcast)
{
$business = $request->user()->currentBusiness;
if ($broadcast->business_id !== $business->id) {
abort(403);
}
$stats = $this->broadcastService->getStatistics($broadcast);
return response()->json([
'status' => $broadcast->status,
'stats' => $stats,
'progress' => $broadcast->total_recipients > 0
? round(($broadcast->total_sent / $broadcast->total_recipients) * 100, 2)
: 0,
]);
}
/**
* View recipients
*/
public function recipients(Request $request, Broadcast $broadcast)
{
$business = $request->user()->currentBusiness;
if ($broadcast->business_id !== $business->id) {
abort(403);
}
$recipients = $broadcast->recipients()
->with('user')
->when($request->has('status'), function ($query) use ($request) {
$query->where('status', $request->status);
})
->orderBy('created_at', 'desc')
->paginate(50);
return view('seller.marketing.broadcasts.recipients', compact('broadcast', 'recipients'));
}
/**
* View analytics
*/
public function analytics(Request $request, Broadcast $broadcast)
{
$business = $request->user()->currentBusiness;
if ($broadcast->business_id !== $business->id) {
abort(403);
}
$stats = $this->broadcastService->getStatistics($broadcast);
// Get hourly breakdown
$hourlyData = DB::table('broadcast_recipients')
->where('broadcast_id', $broadcast->id)
->whereNotNull('sent_at')
->select(
DB::raw('DATE_FORMAT(sent_at, "%Y-%m-%d %H:00:00") as hour'),
DB::raw('COUNT(*) as count')
)
->groupBy('hour')
->orderBy('hour')
->get();
// Get event breakdown by type
$eventBreakdown = DB::table('broadcast_events')
->where('broadcast_id', $broadcast->id)
->select('event', DB::raw('COUNT(*) as count'))
->groupBy('event')
->pluck('count', 'event');
// Top clicked links
$topLinks = DB::table('broadcast_events')
->where('broadcast_id', $broadcast->id)
->where('event', 'clicked')
->select('link_url', DB::raw('COUNT(*) as count'))
->groupBy('link_url')
->orderByDesc('count')
->limit(10)
->get();
return view('seller.marketing.broadcasts.analytics', compact(
'broadcast',
'stats',
'hourlyData',
'eventBreakdown',
'topLinks'
));
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Models\Brand;
use App\Models\Product;
class MarketplacePreviewController extends Controller
{
/**
* Show brand marketplace preview for sellers
* This allows sellers to preview how their brand appears on the marketplace
*/
public function showBrand($brandSlug)
{
// Find brand by slug
$brand = Brand::query()
->where('slug', $brandSlug)
->active()
->firstOrFail();
// Verify the authenticated seller owns this brand
$userBusiness = auth()->user()->businesses->first();
if (! $userBusiness || $brand->business_id !== $userBusiness->id) {
abort(403, 'You do not have permission to preview this brand.');
}
// Get featured products from this brand
$featuredProducts = Product::query()
->with(['strain'])
->where('brand_id', $brand->id)
->featured()
->inStock()
->limit(3)
->get();
// Get all products from this brand
$products = Product::query()
->with(['strain'])
->where('brand_id', $brand->id)
->active()
->orderBy('is_featured', 'desc')
->orderBy('name')
->paginate(20);
$business = $userBusiness;
$isSellerPreview = true; // Flag to indicate this is seller preview mode
return view('seller.marketplace.brand-preview', compact('brand', 'featuredProducts', 'products', 'business', 'isSellerPreview'));
}
}

View File

@@ -0,0 +1,100 @@
<?php
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\ProductCategory;
use Illuminate\Http\Request;
class ProductCategoryController extends Controller
{
/**
* Display a listing of product categories
*/
public function index(Request $request, Business $business)
{
$query = ProductCategory::where('business_id', $business->id);
// Search filter
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('name', 'LIKE', "%{$search}%")
->orWhere('description', 'LIKE', "%{$search}%");
});
}
// Sort
$sortBy = $request->get('sort_by', 'name');
$sortDir = $request->get('sort_dir', 'asc');
$query->orderBy($sortBy, $sortDir);
$categories = $query->with('products')->paginate(20)->withQueryString();
return view('seller.categories.products.index', compact('business', 'categories'));
}
/**
* Store a newly created product category
*/
public function store(Request $request, Business $business)
{
$validated = $request->validate([
'name' => 'required|string|max:255|unique:product_categories,name,NULL,id,business_id,'.$business->id,
'description' => 'nullable|string',
'is_active' => 'boolean',
]);
$validated['business_id'] = $business->id;
$validated['is_active'] = $request->has('is_active');
$category = ProductCategory::create($validated);
return back()->with('success', "Product category '{$category->name}' created successfully!");
}
/**
* Update the specified product category
*/
public function update(Request $request, Business $business, ProductCategory $productCategory)
{
// Verify category belongs to this business
if ($productCategory->business_id !== $business->id) {
abort(403);
}
$validated = $request->validate([
'name' => 'required|string|max:255|unique:product_categories,name,'.$productCategory->id.',id,business_id,'.$business->id,
'description' => 'nullable|string',
'is_active' => 'boolean',
]);
$validated['is_active'] = $request->has('is_active');
$productCategory->update($validated);
return back()->with('success', "Product category '{$productCategory->name}' updated successfully!");
}
/**
* Remove the specified product category
*/
public function destroy(Business $business, ProductCategory $productCategory)
{
// Verify category belongs to this business
if ($productCategory->business_id !== $business->id) {
abort(403);
}
// Check if category is in use
$productsCount = $productCategory->products()->count();
if ($productsCount > 0) {
return back()->with('error', "Cannot delete category '{$productCategory->name}' because it has {$productsCount} associated products.");
}
$name = $productCategory->name;
$productCategory->delete();
return back()->with('success', "Product category '{$name}' deleted successfully!");
}
}

View File

@@ -191,6 +191,9 @@ class ProductController extends Controller
'discontinued' => 'Discontinued',
];
// Get audit history for this product
$audits = $product->audits()->with('user')->latest()->paginate(10);
return view('seller.products.edit', compact(
'business',
'product',
@@ -200,7 +203,8 @@ class ProductController extends Controller
'units',
'productLines',
'productTypes',
'statusOptions'
'statusOptions',
'audits'
));
}
@@ -242,6 +246,9 @@ class ProductController extends Controller
'discontinued' => 'Discontinued',
];
// Get audit history for this product
$audits = $product->audits()->with('user')->latest()->paginate(10);
return view('seller.products.edit1', compact(
'business',
'product',
@@ -251,7 +258,8 @@ class ProductController extends Controller
'units',
'productLines',
'productTypes',
'statusOptions'
'statusOptions',
'audits'
));
}

View File

@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Product;
use App\Models\ProductImage;
use App\Services\ImageBackgroundRemovalService;
use App\Traits\FileStorageHelper;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
@@ -40,6 +41,16 @@ class ProductImageController extends Controller
// Store the image using trait method
$path = $this->storeFile($request->file('image'), 'products');
// Remove background from the uploaded image
$backgroundRemovalService = new ImageBackgroundRemovalService;
$fullPath = storage_path('app/public/'.$path);
$processedPath = $backgroundRemovalService->removeWhiteBackground($fullPath);
// Update path if it changed (e.g., converted from JPG to PNG)
if ($processedPath && $processedPath !== $fullPath) {
$path = str_replace(storage_path('app/public/'), '', $processedPath);
}
// Determine if this should be the primary image (first one)
$isPrimary = $product->images()->count() === 0;

View File

@@ -8,6 +8,89 @@ use Illuminate\Http\Request;
class SettingsController extends Controller
{
/**
* Display the personal profile page.
*/
public function profile(Business $business)
{
$user = auth()->user();
// Get login history (assuming a login_histories table exists)
$loginHistory = collect(); // Placeholder - will be implemented with login history tracking
return view('seller.settings.profile', compact('business', 'loginHistory'));
}
/**
* Update the personal profile.
*/
public function updateProfile(Business $business, Request $request)
{
$user = auth()->user();
$validated = $request->validate([
'first_name' => 'required|string|max:255',
'last_name' => 'required|string|max:255',
'email' => 'required|email|max:255|unique:users,email,'.$user->id,
'avatar' => 'nullable|image|max:2048',
'remove_avatar' => 'nullable|boolean',
'use_gravatar' => 'nullable|boolean',
'linkedin_url' => 'nullable|url|max:255',
'twitter_url' => 'nullable|url|max:255',
'facebook_url' => 'nullable|url|max:255',
'instagram_url' => 'nullable|url|max:255',
'github_url' => 'nullable|url|max:255',
]);
// Handle avatar removal
if ($request->has('remove_avatar') && $user->avatar_path) {
\Storage::disk('public')->delete($user->avatar_path);
$validated['avatar_path'] = null;
}
// Handle avatar upload
if ($request->hasFile('avatar')) {
// Delete old avatar if exists
if ($user->avatar_path) {
\Storage::disk('public')->delete($user->avatar_path);
}
$path = $request->file('avatar')->store('avatars', 'public');
$validated['avatar_path'] = $path;
}
$user->update($validated);
return redirect()->route('seller.business.settings.profile', $business->slug)
->with('success', 'Profile updated successfully.');
}
/**
* Update the user's password.
*/
public function updatePassword(Business $business, Request $request)
{
$user = auth()->user();
$validated = $request->validate([
'current_password' => 'required|current_password',
'password' => 'required|string|min:8|confirmed',
'logout_other_sessions' => 'nullable|boolean',
]);
$user->update([
'password' => bcrypt($validated['password']),
]);
// Logout other sessions if requested
if ($request->has('logout_other_sessions')) {
auth()->logoutOtherDevices($validated['password']);
}
return redirect()->route('seller.business.settings.profile', $business->slug)
->with('success', 'Password updated successfully.');
}
/**
* Display the company information settings page.
*/
@@ -18,21 +101,12 @@ class SettingsController extends Controller
/**
* Update the company information.
* Note: Only business_phone and business_email can be updated due to compliance requirements.
*/
public function updateCompanyInformation(Business $business, Request $request)
{
// Only allow updating business phone and email (hybrid approach)
$validated = $request->validate([
'name' => 'required|string|max:255',
'dba_name' => 'nullable|string|max:255',
'description' => 'nullable|string|max:1000',
'business_type' => 'nullable|string',
'tin_ein' => 'nullable|string|max:20',
'license_number' => 'nullable|string|max:255',
'license_type' => 'nullable|string',
'physical_address' => 'nullable|string|max:255',
'physical_city' => 'nullable|string|max:100',
'physical_state' => 'nullable|string|max:2',
'physical_zipcode' => 'nullable|string|max:10',
'business_phone' => 'nullable|string|max:20',
'business_email' => 'nullable|email|max:255',
]);
@@ -41,39 +115,233 @@ class SettingsController extends Controller
return redirect()
->route('seller.business.settings.company-information', $business->slug)
->with('success', 'Company information updated successfully!');
->with('success', 'Contact information updated successfully!');
}
/**
* Display the users management settings page.
*/
public function users(Business $business)
public function users(Business $business, Request $request, \App\Services\PermissionService $permissionService)
{
return view('seller.settings.users', compact('business'));
// Exclude business owner from the users list
$query = $business->users()->where('users.id', '!=', $business->owner_user_id);
// Search
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
}
// Filter by account type (role)
if ($request->filled('account_type')) {
$query->whereHas('roles', function ($q) use ($request) {
$q->where('name', $request->account_type);
});
}
// Filter by last login date range
if ($request->filled('last_login_start')) {
$query->where('last_login_at', '>=', $request->last_login_start);
}
if ($request->filled('last_login_end')) {
$query->where('last_login_at', '<=', $request->last_login_end.' 23:59:59');
}
$users = $query
->withPivot('contact_type', 'is_primary', 'permissions', 'role', 'role_template')
->with('roles')
->orderBy('last_name')
->orderBy('first_name')
->paginate(15);
$roleTemplates = $permissionService->getRoleTemplates();
$permissionCategories = $permissionService->getPermissionsByCategory();
return view('seller.settings.users', compact('business', 'users', 'roleTemplates', 'permissionCategories'));
}
/**
* Display the order settings page.
* Show the form for editing a user's permissions.
*/
public function orders(Business $business)
public function editUser(Business $business, \App\Models\User $user, \App\Services\PermissionService $permissionService)
{
return view('seller.settings.orders', compact('business'));
// Check if user belongs to this business
if (! $business->users()->where('users.id', $user->id)->exists()) {
abort(403, 'User does not belong to this business');
}
// Load user with pivot data
$user = $business->users()
->withPivot('contact_type', 'is_primary', 'permissions', 'role', 'role_template')
->with('roles')
->where('users.id', $user->id)
->first();
$roleTemplates = $permissionService->getRoleTemplates();
$permissionCategories = $permissionService->getPermissionsByCategory();
$isOwner = $business->owner_user_id === $user->id;
return view('seller.settings.users-edit', compact('business', 'user', 'roleTemplates', 'permissionCategories', 'isOwner'));
}
/**
* Display the brands management page.
* Store a newly created user invitation.
*/
public function brands(Business $business)
public function inviteUser(Business $business, Request $request)
{
return view('seller.settings.brands', compact('business'));
$validated = $request->validate([
'first_name' => 'required|string|max:255',
'last_name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email',
'phone' => 'nullable|string|max:20',
'position' => 'nullable|string|max:255',
'role' => 'required|string|in:company-owner,company-manager,company-user,company-sales,company-accounting,company-manufacturing,company-processing',
'is_point_of_contact' => 'nullable|boolean',
]);
// Combine first and last name
$fullName = trim($validated['first_name'].' '.$validated['last_name']);
// Create user and associate with business
$user = \App\Models\User::create([
'name' => $fullName,
'email' => $validated['email'],
'phone' => $validated['phone'],
'password' => bcrypt(str()->random(32)), // Temporary password
]);
// Assign role
$user->assignRole($validated['role']);
// Associate with business with additional pivot data
$business->users()->attach($user->id, [
'role' => $validated['role'],
'is_primary' => false,
'contact_type' => $request->has('is_point_of_contact') ? 'primary' : null,
]);
// TODO: Send invitation email with password reset link
return redirect()
->route('seller.business.settings.users', $business->slug)
->with('success', 'User invited successfully!');
}
/**
* Display the payment settings page.
* Update user information and permissions.
*/
public function payments(Business $business)
public function updateUser(Business $business, \App\Models\User $user, Request $request)
{
return view('seller.settings.payments', compact('business'));
// Check if user belongs to this business
if (! $business->users()->where('users.id', $user->id)->exists()) {
abort(403, 'User does not belong to this business');
}
// Prevent modifying business owner
if ($business->owner_user_id === $user->id) {
return redirect()
->route('seller.business.settings.users.edit', [$business->slug, $user->id])
->with('error', 'Cannot modify business owner permissions.');
}
$validated = $request->validate([
'position' => 'nullable|string|max:255',
'company' => 'nullable|string|max:255',
'contact_type' => 'nullable|string|max:255',
'role' => 'nullable|string|max:255',
'role_template' => 'nullable|string|max:255',
'permissions' => 'nullable|array',
]);
// Update user data
$user->update([
'position' => $validated['position'] ?? null,
'company' => $validated['company'] ?? null,
]);
// Update business_user pivot data
$business->users()->updateExistingPivot($user->id, [
'contact_type' => $validated['contact_type'] ?? null,
'role' => $validated['role'] ?? null,
'role_template' => $validated['role_template'] ?? null,
'permissions' => $validated['permissions'] ?? null,
'permissions_updated_at' => now(),
]);
return redirect()
->route('seller.business.settings.users.edit', [$business->slug, $user->id])
->with('success', 'User updated successfully!');
}
/**
* Remove user from business.
*/
public function removeUser(Business $business, \App\Models\User $user)
{
// Check if user belongs to this business
if (! $business->users()->where('users.id', $user->id)->exists()) {
abort(403, 'User does not belong to this business');
}
// Detach user from business
$business->users()->detach($user->id);
return redirect()
->route('seller.business.settings.users', $business->slug)
->with('success', 'User removed successfully!');
}
/**
* Display the sales configuration page (orders + invoices).
*/
public function salesConfig(Business $business)
{
return view('seller.settings.sales-config', compact('business'));
}
/**
* Update the sales configuration settings (orders + invoices).
*/
public function updateSalesConfig(Business $business, Request $request)
{
$validated = $request->validate([
// Order settings
'separate_orders_by_brand' => 'nullable|boolean',
'auto_increment_order_ids' => 'nullable|boolean',
'show_mark_as_paid' => 'nullable|boolean',
'display_crm_license_on_orders' => 'nullable|boolean',
'order_minimum' => 'nullable|numeric|min:0',
'default_shipping_charge' => 'nullable|numeric|min:0',
'free_shipping_minimum' => 'nullable|numeric|min:0',
'order_disclaimer' => 'nullable|string|max:2000',
'order_invoice_footer' => 'nullable|string|max:1000',
'prevent_order_editing' => 'required|in:never,after_approval,after_fulfillment,always',
'az_require_patient_count' => 'nullable|boolean',
'az_require_allotment_verification' => 'nullable|boolean',
// Invoice settings
'invoice_payable_company_name' => 'nullable|string|max:255',
'invoice_payable_address' => 'nullable|string|max:255',
'invoice_payable_city' => 'nullable|string|max:100',
'invoice_payable_state' => 'nullable|string|max:2',
'invoice_payable_zipcode' => 'nullable|string|max:10',
]);
// Convert checkbox values (null means unchecked)
$validated['separate_orders_by_brand'] = $request->has('separate_orders_by_brand');
$validated['auto_increment_order_ids'] = $request->has('auto_increment_order_ids');
$validated['show_mark_as_paid'] = $request->has('show_mark_as_paid');
$validated['display_crm_license_on_orders'] = $request->has('display_crm_license_on_orders');
$validated['az_require_patient_count'] = $request->has('az_require_patient_count');
$validated['az_require_allotment_verification'] = $request->has('az_require_allotment_verification');
$business->update($validated);
return redirect()
->route('seller.business.settings.sales-config', $business->slug)
->with('success', 'Sales configuration updated successfully!');
}
/**
@@ -84,12 +352,32 @@ class SettingsController extends Controller
return view('seller.settings.invoices', compact('business'));
}
/**
* Display the brand kit page (Cannabrands assets/branding settings).
*/
public function brandKit(Business $business)
{
return view('seller.settings.brand-kit', compact('business'));
}
/**
* Display the payment settings page.
*/
public function payments(Business $business)
{
return view('seller.settings.payments', compact('business'));
}
/**
* Display the manage licenses page.
*/
public function manageLicenses(Business $business)
{
return view('seller.settings.manage-licenses', compact('business'));
// TODO: License table is currently a placeholder - needs migration update
// For now, return empty collection so the UI displays properly
$licenses = collect([]);
return view('seller.settings.manage-licenses', compact('business', 'licenses'));
}
/**
@@ -100,6 +388,150 @@ class SettingsController extends Controller
return view('seller.settings.plans-and-billing', compact('business'));
}
/**
* Change the business subscription plan.
*/
public function changePlan(Business $business, Request $request)
{
$validated = $request->validate([
'plan_id' => 'required|in:standard,business,premium',
]);
$planId = $validated['plan_id'];
// Define available plans with pricing
$plans = [
'standard' => ['name' => 'Marketplace Standard', 'price' => 99.00],
'business' => ['name' => 'Marketplace Business', 'price' => 395.00],
'premium' => ['name' => 'Marketplace Premium', 'price' => 795.00],
];
$newPlan = $plans[$planId];
// Get or create subscription
$subscription = $business->subscription()->firstOrCreate(
['business_id' => $business->id],
[
'plan_id' => 'standard',
'plan_name' => 'Marketplace Standard',
'plan_price' => 99.00,
'status' => 'active',
'current_period_start' => now(),
'current_period_end' => now()->addMonth(),
]
);
// Check if same plan
if ($subscription->plan_id === $planId) {
return redirect()
->route('seller.business.settings.plans-and-billing', $business->slug)
->with('info', 'You are already on this plan.');
}
// Determine if upgrade or downgrade
$isUpgrade = $newPlan['price'] > $subscription->plan_price;
if ($isUpgrade) {
// UPGRADE: Calculate prorated charge and update immediately
$daysLeftInCycle = now()->diffInDays($subscription->current_period_end);
$proratedCredit = ($subscription->plan_price / 30) * $daysLeftInCycle;
$proratedCharge = ($newPlan['price'] / 30) * $daysLeftInCycle;
$amountToPay = $proratedCharge - $proratedCredit;
// Create invoice for the upgrade
$invoiceNumber = 'INV-'.now()->format('Y').'-'.str_pad(\App\Models\SubscriptionInvoice::count() + 1, 5, '0', STR_PAD_LEFT);
$invoice = \App\Models\SubscriptionInvoice::create([
'subscription_id' => $subscription->id,
'business_id' => $business->id,
'invoice_number' => $invoiceNumber,
'type' => 'upgrade',
'amount' => $amountToPay,
'status' => 'pending',
'invoice_date' => now(),
'due_date' => now()->addDays(7),
'line_items' => [
[
'description' => "{$newPlan['name']} (prorated for {$daysLeftInCycle} days)",
'amount' => $proratedCharge,
],
[
'description' => "Credit from {$subscription->plan_name}",
'amount' => -$proratedCredit,
],
],
'payment_method_id' => $subscription->default_payment_method_id,
]);
// Update subscription to new plan immediately
$subscription->update([
'plan_id' => $planId,
'plan_name' => $newPlan['name'],
'plan_price' => $newPlan['price'],
'scheduled_plan_id' => null,
'scheduled_plan_name' => null,
'scheduled_plan_price' => null,
'scheduled_change_date' => null,
]);
// TODO: Charge the payment method for $amountToPay
// TODO: Mark invoice as paid after successful charge
return redirect()
->route('seller.business.settings.plans-and-billing', $business->slug)
->with('success', sprintf(
'Plan upgraded to %s! Invoice %s created for $%s (prorated). New features are active immediately.',
$newPlan['name'],
$invoiceNumber,
number_format($amountToPay, 2)
));
} else {
// DOWNGRADE: Schedule for next billing cycle
$subscription->update([
'scheduled_plan_id' => $planId,
'scheduled_plan_name' => $newPlan['name'],
'scheduled_plan_price' => $newPlan['price'],
'scheduled_change_date' => $subscription->current_period_end,
]);
return redirect()
->route('seller.business.settings.plans-and-billing', $business->slug)
->with('info', sprintf(
'Plan will be downgraded to %s on %s. You\'ll continue to have access to %s features until then.',
$newPlan['name'],
$subscription->current_period_end->format('F j, Y'),
$subscription->plan_name
));
}
}
/**
* Cancel a scheduled plan downgrade.
*/
public function cancelDowngrade(Business $business)
{
$subscription = $business->subscription;
if (! $subscription || ! $subscription->hasScheduledDowngrade()) {
return redirect()
->route('seller.business.settings.plans-and-billing', $business->slug)
->with('error', 'No scheduled downgrade found.');
}
// Cancel the scheduled downgrade
$subscription->update([
'scheduled_plan_id' => null,
'scheduled_plan_name' => null,
'scheduled_plan_price' => null,
'scheduled_change_date' => null,
]);
return redirect()
->route('seller.business.settings.plans-and-billing', $business->slug)
->with('success', 'Scheduled plan downgrade has been cancelled. You will remain on your current plan.');
}
/**
* Display the notification preferences page.
*/
@@ -108,6 +540,65 @@ class SettingsController extends Controller
return view('seller.settings.notifications', compact('business'));
}
/**
* Update the notification settings.
*
* EMAIL NOTIFICATION RULES DOCUMENTATION:
*
* 1. NEW ORDER EMAIL NOTIFICATIONS (new_order_email_notifications)
* Base: Email these addresses when a new order is placed
* - If 'new_order_only_when_no_sales_rep' checked: ONLY send if buyer has NO sales rep assigned
* - If 'new_order_do_not_send_to_admins' checked: Do NOT send to company admins (only to these addresses)
*
* 2. ORDER ACCEPTED EMAIL NOTIFICATIONS (order_accepted_email_notifications)
* Base: Email these addresses when an order is accepted
* - If 'enable_shipped_emails_for_sales_reps' checked: Sales reps assigned to customer get email when order marked Shipped
*
* 3. PLATFORM INQUIRY EMAIL NOTIFICATIONS (platform_inquiry_email_notifications)
* Base: Email these addresses for inquiries
* - Sales reps associated with customer ALWAYS receive email
* - If field is blank AND no sales reps exist: company admins receive notifications
*
* 4. MANUAL ORDER EMAIL NOTIFICATIONS
* - If 'enable_manual_order_email_notifications' checked: Send same emails for manual orders as buyer-created orders
* - If 'enable_manual_order_email_notifications' unchecked: Only send for buyer-created orders
* - If 'manual_order_emails_internal_only' checked: Send manual order emails to internal recipients only (not buyers)
*
* 5. LOW INVENTORY EMAIL NOTIFICATIONS (low_inventory_email_notifications)
* Base: Email these addresses when inventory is low
*
* 6. CERTIFIED SELLER STATUS EMAIL NOTIFICATIONS (certified_seller_status_email_notifications)
* Base: Email these addresses when seller status changes
*/
public function updateNotifications(Business $business, Request $request)
{
$validated = $request->validate([
'new_order_email_notifications' => ['nullable', 'string', new \App\Rules\CommaSeparatedEmails],
'new_order_only_when_no_sales_rep' => 'nullable|boolean',
'new_order_do_not_send_to_admins' => 'nullable|boolean',
'order_accepted_email_notifications' => ['nullable', 'string', new \App\Rules\CommaSeparatedEmails],
'enable_shipped_emails_for_sales_reps' => 'nullable|boolean',
'platform_inquiry_email_notifications' => ['nullable', 'string', new \App\Rules\CommaSeparatedEmails],
'enable_manual_order_email_notifications' => 'nullable|boolean',
'manual_order_emails_internal_only' => 'nullable|boolean',
'low_inventory_email_notifications' => ['nullable', 'string', new \App\Rules\CommaSeparatedEmails],
'certified_seller_status_email_notifications' => ['nullable', 'string', new \App\Rules\CommaSeparatedEmails],
]);
// Convert checkbox values (null means unchecked)
$validated['new_order_only_when_no_sales_rep'] = $request->has('new_order_only_when_no_sales_rep');
$validated['new_order_do_not_send_to_admins'] = $request->has('new_order_do_not_send_to_admins');
$validated['enable_shipped_emails_for_sales_reps'] = $request->has('enable_shipped_emails_for_sales_reps');
$validated['enable_manual_order_email_notifications'] = $request->has('enable_manual_order_email_notifications');
$validated['manual_order_emails_internal_only'] = $request->has('manual_order_emails_internal_only');
$business->update($validated);
return redirect()
->route('seller.business.settings.notifications', $business->slug)
->with('success', 'Notification settings updated successfully!');
}
/**
* Display the report settings page.
*/
@@ -115,4 +606,157 @@ class SettingsController extends Controller
{
return view('seller.settings.reports', compact('business'));
}
/**
* Display the integrations page.
*/
public function integrations(Business $business)
{
return view('seller.settings.integrations', compact('business'));
}
/**
* Display the webhooks / API page.
*/
public function webhooks(Business $business)
{
return view('seller.settings.webhooks', compact('business'));
}
/**
* Display the audit logs page.
*/
public function auditLogs(Business $business, Request $request)
{
// CRITICAL: Only show audit logs for THIS business (multi-tenancy)
$query = \App\Models\AuditLog::forBusiness($business->id)
->with(['user', 'auditable']);
// Search filter
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('description', 'like', "%{$search}%")
->orWhere('event', 'like', "%{$search}%")
->orWhereHas('user', function ($userQuery) use ($search) {
$userQuery->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
});
}
// Filter by event type
if ($request->filled('event')) {
$query->byEvent($request->event);
}
// Filter by auditable type (resource type)
if ($request->filled('type')) {
$query->byType($request->type);
}
// Filter by user
if ($request->filled('user_id')) {
$query->forUser($request->user_id);
}
// Filter by date range
if ($request->filled('start_date')) {
$query->where('created_at', '>=', $request->start_date);
}
if ($request->filled('end_date')) {
$query->where('created_at', '<=', $request->end_date.' 23:59:59');
}
// Get paginated results, ordered by most recent first
$audits = $query->latest('created_at')->paginate(50);
// Get unique event types for filter dropdown
$eventTypes = \App\Models\AuditLog::forBusiness($business->id)
->select('event')
->distinct()
->pluck('event')
->sort();
// Get unique auditable types for filter dropdown
$auditableTypes = \App\Models\AuditLog::forBusiness($business->id)
->select('auditable_type')
->whereNotNull('auditable_type')
->distinct()
->get()
->map(function ($log) {
$parts = explode('\\', $log->auditable_type);
return end($parts);
})
->unique()
->sort();
return view('seller.settings.audit-logs', compact('business', 'audits', 'eventTypes', 'auditableTypes'));
}
/**
* View an invoice.
*/
public function viewInvoice(Business $business, string $invoiceId)
{
// TODO: Fetch actual invoice from database
$invoice = [
'id' => $invoiceId,
'date' => now()->subDays(rand(1, 90)),
'amount' => 395.00,
'status' => 'paid',
'items' => [
['description' => 'Marketplace Business Plan', 'quantity' => 1, 'price' => 395.00],
],
];
return view('seller.settings.invoice-view', compact('business', 'invoice'));
}
/**
* Download an invoice as PDF.
*/
public function downloadInvoice(Business $business, string $invoiceId)
{
// TODO: Generate actual PDF from invoice data
// For now, return a mock PDF
$invoice = [
'id' => $invoiceId,
'date' => now()->subDays(rand(1, 90)),
'amount' => 395.00,
'status' => 'paid',
'business_name' => $business->name,
'business_address' => $business->physical_address,
];
// Generate a simple mock PDF content
$pdfContent = "INVOICE #{$invoice['id']}\n\n";
$pdfContent .= "Date: {$invoice['date']->format('m/d/Y')}\n";
$pdfContent .= "Business: {$invoice['business_name']}\n";
$pdfContent .= 'Amount: $'.number_format($invoice['amount'], 2)."\n";
$pdfContent .= 'Status: '.strtoupper($invoice['status'])."\n\n";
$pdfContent .= "This is a mock invoice for testing purposes.\n";
$pdfContent .= "In production, this would be a properly formatted PDF.\n";
return response($pdfContent, 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'attachment; filename="invoice-'.$invoiceId.'.pdf"',
]);
}
/**
* Switch the current view (sales, manufacturing, compliance).
*/
public function switchView(Request $request)
{
$validated = $request->validate([
'view' => 'required|in:sales,manufacturing,compliance',
]);
session(['current_view' => $validated['view']]);
return redirect()->back()->with('success', 'View switched successfully');
}
}

View File

@@ -0,0 +1,231 @@
<?php
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Models\Batch;
use App\Models\Business;
use App\Models\Conversion;
use Illuminate\Http\Request;
class WashReportController extends Controller
{
/**
* Display a listing of wash reports
*/
public function index(Business $business)
{
$conversions = Conversion::where('business_id', $business->id)
->where('conversion_type', 'hash_wash')
->with(['operator', 'inputBatches', 'batchCreated'])
->orderBy('created_at', 'desc')
->paginate(20);
return view('seller.wash-reports.index', compact('business', 'conversions'));
}
/**
* Show Stage 1 form (wash parameters)
*/
public function createStage1(Business $business)
{
// Get available Fresh Frozen input material batches for this business
$inputBatches = Batch::where('business_id', $business->id)
->where('quantity_remaining', '>', 0)
->where('is_tested', true)
->where('is_quarantined', false)
->orderBy('batch_number')
->get();
return view('seller.wash-reports.stage1', compact('business', 'inputBatches'));
}
/**
* Store Stage 1 data and redirect to Stage 2
*/
public function storeStage1(Business $business, Request $request)
{
$validated = $request->validate([
'wash_date' => 'nullable|date',
'input_batch_id' => 'nullable|exists:batches,id',
'starting_weight' => 'nullable|numeric|min:0',
'soak_time_minutes' => 'nullable|integer|min:0',
'room_temperature_f' => 'nullable|numeric',
'vessel_temperature_f' => 'nullable|numeric',
'strain' => 'nullable|string|max:255',
'wash_cycles' => 'nullable|array',
'wash_cycles.*.cycle' => 'nullable|integer|min:1',
'wash_cycles.*.forward_speed' => 'nullable|integer|min:1|max:10',
'wash_cycles.*.reverse_speed' => 'nullable|integer|min:1|max:10',
'wash_cycles.*.pause' => 'nullable|integer|min:0',
'wash_cycles.*.run_time' => 'nullable|integer|min:1',
'notes' => 'nullable|string',
]);
// Verify batch belongs to business (skip validation if testing)
$inputBatch = null;
if (isset($validated['input_batch_id'])) {
$inputBatch = Batch::where('business_id', $business->id)
->where('id', $validated['input_batch_id'])
->first();
// Verify sufficient quantity available
if ($inputBatch && isset($validated['starting_weight']) && $inputBatch->quantity_remaining < $validated['starting_weight']) {
return back()->withErrors([
'starting_weight' => 'Insufficient quantity available. Only '.$inputBatch->quantity_remaining.'g available.',
])->withInput();
}
}
// Create conversion with Stage 1 data
$strain = ! empty($validated['strain']) ? $validated['strain'] : 'Test';
$internalName = $strain.' Hash Wash #'.now()->format('Ymd-His');
$conversion = Conversion::create([
'business_id' => $business->id,
'conversion_type' => 'hash_wash',
'status' => 'in_progress',
'internal_name' => $internalName,
'started_at' => ! empty($validated['wash_date']) ? $validated['wash_date'] : now(),
'operator_user_id' => auth()->id(),
'metadata' => [
'stage_1' => [
'wash_date' => $validated['wash_date'] ?? null,
'cultivator' => ($inputBatch && isset($inputBatch->cultivator)) ? $inputBatch->cultivator : 'Unknown',
'starting_weight' => isset($validated['starting_weight']) ? (float) $validated['starting_weight'] : 0,
'soak_time_minutes' => isset($validated['soak_time_minutes']) ? (int) $validated['soak_time_minutes'] : 0,
'room_temperature_f' => isset($validated['room_temperature_f']) ? (float) $validated['room_temperature_f'] : 0,
'vessel_temperature_f' => isset($validated['vessel_temperature_f']) ? (float) $validated['vessel_temperature_f'] : 0,
'strain' => $strain,
'wash_cycles' => $validated['wash_cycles'] ?? [],
],
],
'notes' => $validated['notes'] ?? null,
]);
// Link input batch to conversion (if batch was selected)
if ($inputBatch) {
$conversion->inputBatches()->attach($inputBatch->id, [
'role' => 'input',
'quantity_used' => $validated['starting_weight'] ?? 0,
'unit' => 'g',
]);
}
// Redirect to Stage 2
return redirect()->route('seller.business.wash-reports.stage2', [
'business' => $business->slug,
'conversion' => $conversion->id,
])->with('success', 'Stage 1 completed. Now enter yield details.');
}
/**
* Show Stage 2 form (yield tracking)
*/
public function createStage2(Business $business, Conversion $conversion)
{
// Verify conversion belongs to business
if ($conversion->business_id !== $business->id) {
abort(403);
}
// Verify Stage 1 is complete
if (! $conversion->getStage1Data()) {
return redirect()->route('seller.business.wash-reports.stage1', $business->slug)
->withErrors(['error' => 'Please complete Stage 1 first.']);
}
$stage1Data = $conversion->getStage1Data();
return view('seller.wash-reports.stage2', compact('business', 'conversion', 'stage1Data'));
}
/**
* Store Stage 2 data and complete conversion
*/
public function storeStage2(Business $business, Conversion $conversion, Request $request)
{
// Verify conversion belongs to business
if ($conversion->business_id !== $business->id) {
abort(403);
}
$validated = $request->validate([
'fresh_press_120u' => 'required|numeric|min:0',
'cold_cure_90u' => 'required|numeric|min:0',
'rosin_45u' => 'required|numeric|min:0',
'green_blonde_160u' => 'required|numeric|min:0',
'green_blonde_25u' => 'required|numeric|min:0',
]);
$stage1Data = $conversion->getStage1Data();
$startingWeight = $stage1Data['starting_weight'];
// Calculate individual percentages
$freshPressPercentage = $startingWeight > 0 ? round(($validated['fresh_press_120u'] / $startingWeight) * 100, 2) : 0;
$coldCurePercentage = $startingWeight > 0 ? round(($validated['cold_cure_90u'] / $startingWeight) * 100, 2) : 0;
$rosinPercentage = $startingWeight > 0 ? round(($validated['rosin_45u'] / $startingWeight) * 100, 2) : 0;
$greenBlonde160Percentage = $startingWeight > 0 ? round(($validated['green_blonde_160u'] / $startingWeight) * 100, 2) : 0;
$greenBlonde25Percentage = $startingWeight > 0 ? round(($validated['green_blonde_25u'] / $startingWeight) * 100, 2) : 0;
// Calculate total yield
$totalYield = $validated['fresh_press_120u']
+ $validated['cold_cure_90u']
+ $validated['rosin_45u']
+ $validated['green_blonde_160u']
+ $validated['green_blonde_25u'];
// Update conversion with Stage 2 data
$metadata = $conversion->metadata;
$metadata['stage_2'] = [
'yields' => [
'fresh_press_120u' => [
'weight' => (float) $validated['fresh_press_120u'],
'percentage' => $freshPressPercentage,
],
'cold_cure_90u' => [
'weight' => (float) $validated['cold_cure_90u'],
'percentage' => $coldCurePercentage,
],
'rosin_45u' => [
'weight' => (float) $validated['rosin_45u'],
'percentage' => $rosinPercentage,
],
'green_blonde_160u' => [
'weight' => (float) $validated['green_blonde_160u'],
'percentage' => $greenBlonde160Percentage,
],
'green_blonde_25u' => [
'weight' => (float) $validated['green_blonde_25u'],
'percentage' => $greenBlonde25Percentage,
],
],
'total_yield' => $totalYield,
];
$conversion->metadata = $metadata;
$conversion->actual_output_quantity = $totalYield;
$conversion->actual_output_unit = 'g';
$conversion->save();
return redirect()->route('seller.business.wash-reports.show', [
'business' => $business->slug,
'conversion' => $conversion->id,
])->with('success', 'Wash report completed successfully! Total yield: '.$totalYield.'g ('.$conversion->getYieldPercentage().'%)');
}
/**
* Display a single wash report
*/
public function show(Business $business, Conversion $conversion)
{
// Verify conversion belongs to business
if ($conversion->business_id !== $business->id) {
abort(403);
}
$conversion->load(['operator', 'inputBatches', 'batchCreated']);
return view('seller.wash-reports.show', compact('business', 'conversion'));
}
}

View File

@@ -10,19 +10,10 @@ class SettingsController extends Controller
/**
* Display the user's account settings.
*/
public function index()
public function index(Business $business)
{
$user = Auth::user();
// Get businesses owned by this user
$ownedBusinesses = Business::where('owner_user_id', $user->id)->get();
// Get eligible owners for each owned business
$eligibleOwners = [];
foreach ($ownedBusinesses as $business) {
$eligibleOwners[$business->id] = $business->getEligibleOwners();
}
return view('settings.index', compact('user', 'ownedBusinesses', 'eligibleOwners'));
return view('settings.index', compact('user', 'business'));
}
}

View File

@@ -0,0 +1,180 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use App\Models\ViewAsSession;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class ViewAsController extends Controller
{
/**
* Start viewing as another user
*/
public function start(Request $request, string $businessSlug, int $userId)
{
try {
$business = currentBusiness();
if (! $business) {
return redirect()->back()->with('error', 'Business not found');
}
// Only owners and admins with view_as permission can impersonate
$canViewAs = auth()->user()->user_type === 'admin' ||
$business->owner_user_id === auth()->id() ||
hasBusinessPermission('users.view_as');
if (! $canViewAs) {
return redirect()->back()->with('error', 'You do not have permission to view as other users');
}
$targetUser = User::findOrFail($userId);
// Verify target user belongs to this business
if (! $targetUser->businesses()->where('businesses.id', $business->id)->exists()) {
return redirect()->back()->with('error', 'User does not belong to this business');
}
// Prevent viewing as business owner or other admins
if ($targetUser->user_type === 'admin' || $targetUser->id === $business->owner_user_id) {
return redirect()->back()->with('error', 'Cannot view as business owner or admin users');
}
// Prevent viewing as yourself
if ($targetUser->id === auth()->id()) {
return redirect()->back()->with('error', 'Cannot view as yourself');
}
// Check for maximum concurrent sessions
$maxConcurrent = config('permissions.view_as.max_concurrent_sessions', 3);
$activeSessions = ViewAsSession::forOriginalUser(auth()->id())
->forBusiness($business->id)
->active()
->count();
if ($activeSessions >= $maxConcurrent) {
return redirect()->back()->with('error', 'Maximum concurrent View As sessions reached. Please end an existing session first.');
}
// Generate unique session ID
$sessionId = Str::random(32);
// Create session record
$session = ViewAsSession::startSession(
businessId: $business->id,
originalUserId: auth()->id(),
viewingAsUserId: $targetUser->id,
sessionId: $sessionId
);
// Store in session
session([
'view_as_session_id' => $sessionId,
'view_as_user_id' => $targetUser->id,
'view_as_original_user_id' => auth()->id(),
]);
Log::info('Started View As session', [
'session_id' => $sessionId,
'business_id' => $business->id,
'original_user_id' => auth()->id(),
'viewing_as_user_id' => $targetUser->id,
]);
return redirect()
->route('seller.business.dashboard', $business->slug)
->with('success', "Now viewing as {$targetUser->name}");
} catch (\Exception $e) {
Log::error('Error starting View As session', [
'error' => $e->getMessage(),
'user_id' => $userId,
'business_slug' => $businessSlug,
]);
return redirect()->back()->with('error', 'Failed to start View As session');
}
}
/**
* End View As session
*/
public function end(Request $request)
{
try {
$sessionId = session('view_as_session_id');
if (! $sessionId) {
return redirect()->back()->with('error', 'No active View As session');
}
// Find and end the session
$session = ViewAsSession::findActiveBySessionId($sessionId);
if ($session) {
$session->end();
Log::info('Ended View As session', [
'session_id' => $sessionId,
'duration_seconds' => $session->duration_seconds,
'pages_viewed' => $session->pages_viewed,
]);
}
// Clear session data
session()->forget([
'view_as_session_id',
'view_as_user_id',
'view_as_original_user_id',
]);
$business = currentBusiness();
return redirect()
->route('seller.business.users.index', $business?->slug ?? 'home')
->with('success', 'View As session ended');
} catch (\Exception $e) {
Log::error('Error ending View As session', [
'error' => $e->getMessage(),
]);
return redirect()->back()->with('error', 'Failed to end View As session');
}
}
/**
* Get current View As session info (for AJAX)
*/
public function status(Request $request)
{
$sessionId = session('view_as_session_id');
if (! $sessionId) {
return response()->json([
'active' => false,
]);
}
$session = ViewAsSession::findActiveBySessionId($sessionId);
if (! $session) {
return response()->json([
'active' => false,
]);
}
return response()->json([
'active' => true,
'viewing_as_name' => $session->viewingAsUser?->name,
'original_user_name' => $session->originalUser?->name,
'started_at' => $session->started_at->toISOString(),
'duration' => $session->formatted_duration,
'remaining_time' => $session->remaining_time,
'pages_viewed' => $session->pages_viewed,
]);
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Http\Controllers;
use App\Helpers\BusinessHelper;
use Illuminate\Http\Request;
class ViewSwitcherController extends Controller
{
/**
* Switch the active view (sales/manufacturing/compliance) for the current session
*/
public function switch(Request $request)
{
$view = $request->input('view');
// Validate view
if (! in_array($view, ['sales', 'manufacturing', 'compliance'])) {
return back()->with('error', 'Invalid view selected');
}
$business = BusinessHelper::current();
if (! $business) {
return back()->with('error', 'No business context');
}
// Check if business has access to this view
if ($view === 'manufacturing' && ! $business->has_manufacturing) {
return back()->with('error', 'Manufacturing module not enabled for this business');
}
if ($view === 'compliance' && ! $business->has_compliance) {
return back()->with('error', 'Compliance module not enabled for this business');
}
// Store selected view in session
session(['current_view' => $view]);
$viewNames = [
'sales' => 'Sales',
'manufacturing' => 'Manufacturing',
'compliance' => 'Compliance',
];
return back()->with('success', 'Switched to '.$viewNames[$view].' view');
}
/**
* Get the currently selected view
*/
public static function getCurrentView(): string
{
return session('current_view', 'sales');
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class UpdateLastLogin
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
if (auth()->check()) {
$user = auth()->user();
// Only update if last login was more than 5 minutes ago (to avoid excessive updates)
if (! $user->last_login_at || $user->last_login_at->lt(now()->subMinutes(5))) {
$user->update(['last_login_at' => now()]);
}
}
return $next($request);
}
}

View File

@@ -0,0 +1,98 @@
<?php
namespace App\Http\Middleware;
use App\Models\ViewAsSession;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
class ViewAsMiddleware
{
/**
* Handle an incoming request.
*
* Manages View As sessions:
* - Validates session exists and is not expired
* - Tracks page visits
* - Auto-ends expired sessions
* - Sets the effective user for permission checks
*/
public function handle(Request $request, Closure $next)
{
$sessionId = session('view_as_session_id');
if (! $sessionId) {
// No View As session active
return $next($request);
}
// Find active session
$session = ViewAsSession::findActiveBySessionId($sessionId);
if (! $session) {
// Session not found - clear session data
$this->clearViewAsSession();
return $next($request);
}
// Check if session is expired
if ($session->isExpired()) {
Log::info('View As session expired', [
'session_id' => $sessionId,
'started_at' => $session->started_at,
]);
$session->end();
$this->clearViewAsSession();
return redirect()
->route('seller.business.users.index', currentBusiness()?->slug ?? 'home')
->with('warning', 'Your View As session has expired');
}
// Verify the original user is still the logged-in user
if ($session->original_user_id !== Auth::id()) {
Log::warning('View As session user mismatch', [
'session_id' => $sessionId,
'session_original_user_id' => $session->original_user_id,
'auth_user_id' => Auth::id(),
]);
$session->end();
$this->clearViewAsSession();
return redirect()
->route('seller.business.users.index', currentBusiness()?->slug ?? 'home')
->with('error', 'View As session authentication failed');
}
// Track page visit if enabled
if (config('permissions.view_as.track_pages', true)) {
$session->trackPage($request->fullUrl());
}
// Set view as context for the request
$request->merge([
'view_as_active' => true,
'view_as_user_id' => $session->viewing_as_user_id,
'view_as_original_user_id' => $session->original_user_id,
]);
return $next($request);
}
/**
* Clear View As session data
*/
protected function clearViewAsSession(): void
{
session()->forget([
'view_as_session_id',
'view_as_user_id',
'view_as_original_user_id',
]);
}
}

View File

@@ -0,0 +1,132 @@
<?php
namespace App\Jobs\Analytics;
use App\Models\Analytics\BuyerEngagementScore;
use App\Models\Analytics\IntentSignal;
use App\Models\Analytics\ProductView;
use App\Models\Analytics\UserSession;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class CalculateEngagementScore implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $businessId;
protected $buyerBusinessId;
public function __construct(int $businessId, int $buyerBusinessId)
{
$this->businessId = $businessId;
$this->buyerBusinessId = $buyerBusinessId;
$this->onQueue('analytics');
}
public function handle(): void
{
$score = BuyerEngagementScore::firstOrNew([
'business_id' => $this->businessId,
'buyer_business_id' => $this->buyerBusinessId,
]);
// Calculate recency score (0-100 points, weighted 25%)
$lastInteraction = ProductView::where('business_id', $this->businessId)
->where('buyer_business_id', $this->buyerBusinessId)
->max('viewed_at');
if ($lastInteraction) {
$daysSince = now()->diffInDays($lastInteraction);
$score->days_since_last_interaction = $daysSince;
$score->last_interaction_at = $lastInteraction;
// Score decreases as days increase
if ($daysSince <= 1) {
$score->recency_score = 100;
} elseif ($daysSince <= 7) {
$score->recency_score = 80;
} elseif ($daysSince <= 30) {
$score->recency_score = 40;
} else {
$score->recency_score = 0;
}
} else {
$score->recency_score = 0;
}
// Calculate frequency score (0-100 points, weighted 25%)
$sessions30d = UserSession::where('business_id', $this->businessId)
->where('buyer_business_id', $this->buyerBusinessId)
->where('started_at', '>=', now()->subDays(30))
->count();
$score->sessions_30d = $sessions30d;
if ($sessions30d >= 20) {
$score->frequency_score = 100;
} elseif ($sessions30d >= 10) {
$score->frequency_score = 80;
} elseif ($sessions30d >= 5) {
$score->frequency_score = 60;
} else {
$score->frequency_score = $sessions30d * 10; // 0-40
}
// Calculate depth score (0-100 points, weighted 30%)
$productViews30d = ProductView::where('business_id', $this->businessId)
->where('buyer_business_id', $this->buyerBusinessId)
->where('viewed_at', '>=', now()->subDays(30))
->count();
$highEngagement = ProductView::where('business_id', $this->businessId)
->where('buyer_business_id', $this->buyerBusinessId)
->where('viewed_at', '>=', now()->subDays(30))
->where(function ($q) {
$q->where('zoomed_image', true)
->orWhere('watched_video', true)
->orWhere('downloaded_spec', true)
->orWhere('added_to_cart', true);
})
->count();
$score->product_views_30d = $productViews30d;
// Base depth score on engagement rate
$engagementRate = $productViews30d > 0 ? ($highEngagement / $productViews30d) * 100 : 0;
$viewScore = min(50, $productViews30d * 2); // Up to 50 points for views
$interactionScore = min(50, $engagementRate / 2); // Up to 50 points for engagement rate
$score->depth_score = min(100, $viewScore + $interactionScore);
// Calculate intent score (0-100 points, weighted 20%)
$intentSignals = IntentSignal::where('business_id', $this->businessId)
->where('buyer_business_id', $this->buyerBusinessId)
->where('detected_at', '>=', now()->subDays(30))
->sum('signal_strength');
$score->intent_score = min(100, $intentSignals * 10);
// Calculate total score (weighted average)
$score->calculateScore();
// Determine tier based on score
if ($score->score >= 80) {
$score->score_tier = 'hot';
} elseif ($score->score >= 60) {
$score->score_tier = 'warm';
} elseif ($score->score >= 40) {
$score->score_tier = 'cool';
} elseif ($score->score >= 20) {
$score->score_tier = 'cold';
} else {
$score->score_tier = 'inactive';
}
$score->calculated_at = now();
$score->save();
}
}

View File

@@ -0,0 +1,249 @@
<?php
namespace App\Jobs;
use App\Models\Analytics\BuyerEngagementScore;
use App\Models\Analytics\EmailInteraction;
use App\Models\Analytics\IntentSignal;
use App\Models\Analytics\ProductView;
use App\Models\Analytics\UserSession;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;
class CalculateEngagementScore implements ShouldQueue
{
use Queueable;
public function __construct(
public int $sellerBusinessId,
public int $buyerBusinessId
) {}
public function handle(): void
{
// Get or create engagement score record
$score = BuyerEngagementScore::firstOrNew([
'business_id' => $this->sellerBusinessId,
'buyer_business_id' => $this->buyerBusinessId,
]);
// Calculate session metrics
$sessionMetrics = UserSession::where('business_id', $this->sellerBusinessId)
->where('user_id', function ($query) {
$query->select('id')
->from('users')
->whereExists(function ($q) {
$q->select(DB::raw(1))
->from('business_user')
->whereColumn('business_user.user_id', 'users.id')
->where('business_user.business_id', $this->buyerBusinessId);
});
})
->selectRaw('
COUNT(*) as total_sessions,
SUM(page_views) as total_page_views,
SUM(product_views) as total_product_views,
MAX(last_activity_at) as last_interaction_at,
MIN(started_at) as first_interaction_at
')
->first();
// Calculate product view metrics
$productMetrics = ProductView::where('business_id', $this->sellerBusinessId)
->where('buyer_business_id', $this->buyerBusinessId)
->selectRaw('
COUNT(DISTINCT product_id) as unique_products_viewed,
SUM(CASE WHEN added_to_cart = 1 THEN 1 ELSE 0 END) as total_cart_additions
')
->first();
// Calculate email metrics
$emailMetrics = EmailInteraction::where('business_id', $this->sellerBusinessId)
->whereExists(function ($query) {
$query->select(DB::raw(1))
->from('users')
->whereColumn('email_interactions.recipient_user_id', 'users.id')
->whereExists(function ($q) {
$q->select(DB::raw(1))
->from('business_user')
->whereColumn('business_user.user_id', 'users.id')
->where('business_user.business_id', $this->buyerBusinessId);
});
})
->selectRaw('
SUM(open_count) as total_email_opens,
SUM(click_count) as total_email_clicks
')
->first();
// Get order metrics (assuming Order model exists)
$orderMetrics = DB::table('orders')
->where('seller_business_id', $this->sellerBusinessId)
->where('buyer_business_id', $this->buyerBusinessId)
->selectRaw('
COUNT(*) as total_orders,
SUM(total) as total_order_value
')
->first();
// Update base metrics
$score->fill([
'total_sessions' => $sessionMetrics->total_sessions ?? 0,
'total_page_views' => $sessionMetrics->total_page_views ?? 0,
'total_product_views' => $sessionMetrics->total_product_views ?? 0,
'unique_products_viewed' => $productMetrics->unique_products_viewed ?? 0,
'total_email_opens' => $emailMetrics->total_email_opens ?? 0,
'total_email_clicks' => $emailMetrics->total_email_clicks ?? 0,
'total_cart_additions' => $productMetrics->total_cart_additions ?? 0,
'total_orders' => $orderMetrics->total_orders ?? 0,
'total_order_value' => $orderMetrics->total_order_value ?? 0,
'last_interaction_at' => $sessionMetrics->last_interaction_at,
'first_interaction_at' => $sessionMetrics->first_interaction_at,
]);
// Calculate recency score (0-100)
$score->updateDaysSinceLastInteraction();
$score->recency_score = $this->calculateRecencyScore($score->days_since_last_interaction);
// Calculate frequency score (0-100)
$score->frequency_score = $this->calculateFrequencyScore(
$score->total_sessions,
$score->total_product_views
);
// Calculate engagement score (0-100)
$score->engagement_score = $this->calculateEngagementScore($score);
// Calculate intent score based on intent signals (0-100)
$intentScore = IntentSignal::where('business_id', $this->sellerBusinessId)
->where('buyer_business_id', $this->buyerBusinessId)
->where('detected_at', '>', now()->subDays(30))
->avg('signal_strength');
$score->intent_score = $intentScore ?? 0;
// Calculate total weighted score
$score->calculateTotalScore();
// Determine engagement trend
$score->engagement_trend = $this->calculateTrend($score);
$score->save();
}
protected function calculateRecencyScore(int $daysSinceLastInteraction): int
{
if ($daysSinceLastInteraction <= 7) {
return 100;
} elseif ($daysSinceLastInteraction <= 14) {
return 80;
} elseif ($daysSinceLastInteraction <= 30) {
return 60;
} elseif ($daysSinceLastInteraction <= 60) {
return 40;
} elseif ($daysSinceLastInteraction <= 90) {
return 20;
}
return 0;
}
protected function calculateFrequencyScore(int $sessions, int $productViews): int
{
$score = 0;
// Session frequency
if ($sessions >= 20) {
$score += 50;
} elseif ($sessions >= 10) {
$score += 35;
} elseif ($sessions >= 5) {
$score += 20;
} elseif ($sessions >= 1) {
$score += 10;
}
// Product view frequency
if ($productViews >= 50) {
$score += 50;
} elseif ($productViews >= 25) {
$score += 35;
} elseif ($productViews >= 10) {
$score += 20;
} elseif ($productViews >= 1) {
$score += 10;
}
return min(100, $score);
}
protected function calculateEngagementScore(BuyerEngagementScore $score): int
{
$engagementScore = 0;
// Email engagement
if ($score->total_email_opens > 0) {
$engagementScore += 15;
}
if ($score->total_email_clicks > 0) {
$engagementScore += 25;
}
// Product engagement
if ($score->unique_products_viewed >= 10) {
$engagementScore += 20;
} elseif ($score->unique_products_viewed >= 5) {
$engagementScore += 10;
}
// Cart activity
if ($score->total_cart_additions > 0) {
$engagementScore += 25;
}
// Order activity
if ($score->total_orders > 0) {
$engagementScore += 15;
}
return min(100, $engagementScore);
}
protected function calculateTrend(BuyerEngagementScore $score): string
{
// If very new (less than 14 days), mark as new
$daysSinceFirst = $score->first_interaction_at
? now()->diffInDays($score->first_interaction_at)
: 0;
if ($daysSinceFirst < 14) {
return BuyerEngagementScore::TREND_NEW;
}
// Compare recent activity (last 14 days) vs previous period
$recentActivity = ProductView::where('business_id', $this->sellerBusinessId)
->where('buyer_business_id', $this->buyerBusinessId)
->where('viewed_at', '>', now()->subDays(14))
->count();
$previousActivity = ProductView::where('business_id', $this->sellerBusinessId)
->where('buyer_business_id', $this->buyerBusinessId)
->whereBetween('viewed_at', [now()->subDays(28), now()->subDays(14)])
->count();
if ($previousActivity == 0) {
return BuyerEngagementScore::TREND_STABLE;
}
$changePercent = (($recentActivity - $previousActivity) / $previousActivity) * 100;
if ($changePercent > 20) {
return BuyerEngagementScore::TREND_INCREASING;
} elseif ($changePercent < -20) {
return BuyerEngagementScore::TREND_DECLINING;
}
return BuyerEngagementScore::TREND_STABLE;
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace App\Jobs;
use App\Models\AuditLog;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
/**
* Async Audit Log Job
*
* Writes audit log entries to database via Horizon queue.
* This prevents performance impact on user-facing requests.
*
* Environments:
* - local: Job won't be dispatched (AuditLogger returns early)
* - dev/production: Processed via Horizon
*/
class LogAuditEntry implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* The number of times the job may be attempted.
*
* @var int
*/
public $tries = 3;
/**
* The number of seconds to wait before retrying the job.
*
* @var int
*/
public $backoff = 10;
/**
* Audit log data
*/
protected array $data;
/**
* Create a new job instance.
*/
public function __construct(array $data)
{
$this->data = $data;
// Use 'audit' queue for better organization in Horizon
$this->onQueue('audit');
}
/**
* Execute the job.
*/
public function handle(): void
{
// Create the audit log entry
AuditLog::create($this->data);
}
/**
* Handle a job failure.
*/
public function failed(\Throwable $exception): void
{
// Log the failure (but don't throw exception to prevent infinite retries)
\Log::error('Failed to create audit log entry', [
'data' => $this->data,
'exception' => $exception->getMessage(),
'trace' => $exception->getTraceAsString(),
]);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Jobs\Marketing;
use App\Models\Broadcast;
use App\Services\Marketing\BroadcastService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class ProcessScheduledBroadcastsJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function handle(BroadcastService $service): void
{
$broadcasts = Broadcast::scheduled()->get();
Log::info('Processing scheduled broadcasts', [
'count' => $broadcasts->count(),
]);
foreach ($broadcasts as $broadcast) {
try {
$service->sendBroadcast($broadcast);
Log::info('Scheduled broadcast started', [
'broadcast_id' => $broadcast->id,
]);
} catch (\Exception $e) {
Log::error('Failed to start scheduled broadcast', [
'broadcast_id' => $broadcast->id,
'error' => $e->getMessage(),
]);
}
}
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Jobs\Marketing;
use App\Models\Broadcast;
use App\Services\Marketing\BroadcastService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class SendBroadcastJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 1;
public int $timeout = 600; // 10 minutes
public function __construct(
public Broadcast $broadcast
) {}
public function handle(BroadcastService $service): void
{
try {
Log::info('Starting broadcast send', [
'broadcast_id' => $this->broadcast->id,
'name' => $this->broadcast->name,
]);
$service->processBroadcastSending($this->broadcast);
Log::info('Broadcast queued successfully', [
'broadcast_id' => $this->broadcast->id,
'recipients' => $this->broadcast->total_recipients,
]);
} catch (\Exception $e) {
Log::error('Broadcast send failed', [
'broadcast_id' => $this->broadcast->id,
'error' => $e->getMessage(),
]);
$this->broadcast->update([
'status' => 'failed',
'finished_sending_at' => now(),
]);
throw $e;
}
}
public function failed(\Throwable $exception): void
{
Log::error('SendBroadcastJob failed permanently', [
'broadcast_id' => $this->broadcast->id,
'error' => $exception->getMessage(),
]);
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Jobs\Marketing;
use App\Models\Broadcast;
use App\Models\BroadcastRecipient;
use App\Services\Marketing\BroadcastService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class SendBroadcastMessageJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 60;
public int $backoff = 30; // Retry after 30 seconds
public function __construct(
public Broadcast $broadcast,
public BroadcastRecipient $recipient
) {}
public function handle(BroadcastService $service): void
{
// Check if broadcast is still sending
if ($this->broadcast->status !== 'sending') {
return;
}
// Check if recipient still needs sending
if (! in_array($this->recipient->status, ['pending', 'queued'])) {
return;
}
// Send the message
$service->sendToRecipient($this->broadcast, $this->recipient);
// Check if broadcast is complete
$service->checkBroadcastCompletion($this->broadcast);
}
public function failed(\Throwable $exception): void
{
$this->recipient->markAsFailed(
$exception->getMessage(),
$exception->getCode()
);
$this->broadcast->increment('total_failed');
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Jobs;
use App\Models\Analytics\AnalyticsEvent;
use App\Models\Analytics\UserSession;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class ProcessAnalyticsEvent implements ShouldQueue
{
use Queueable;
public function __construct(
public array $eventData
) {}
public function handle(): void
{
// Create the analytics event
$event = AnalyticsEvent::create($this->eventData);
// Update related session if exists
if (! empty($this->eventData['session_id'])) {
$session = UserSession::where('session_id', $this->eventData['session_id'])->first();
if ($session) {
$session->updateActivity();
// Update session metrics based on event type
match ($this->eventData['event_type']) {
'product_view' => $session->increment('product_views'),
'cart_add' => $session->update(['cart_additions' => $session->cart_additions + 1]),
'checkout_initiated' => $session->update(['checkout_initiated' => true]),
'order_completed' => $session->update(['order_completed' => true]),
default => null,
};
}
}
// Trigger engagement score recalculation if needed
if ($this->shouldRecalculateEngagement($event)) {
CalculateEngagementScore::dispatch(
$event->business_id,
$this->eventData['buyer_business_id'] ?? $event->business_id
)->onQueue('analytics');
}
}
protected function shouldRecalculateEngagement(AnalyticsEvent $event): bool
{
// Recalculate on significant events
return in_array($event->event_type, [
'product_view',
'email_open',
'email_click',
'cart_add',
'order_completed',
]);
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class BroadcastEmail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public string $emailSubject,
public string $emailBody
) {}
public function build()
{
return $this->subject($this->emailSubject)
->html($this->emailBody);
}
}

View File

@@ -12,7 +12,7 @@ class Address extends Model
{
use HasFactory, SoftDeletes;
// Address Types (LeafLink-aligned)
// Address Types
public const ADDRESS_TYPES = [
'corporate' => 'Corporate Headquarters',
'physical' => 'Physical Location',

View File

@@ -0,0 +1,100 @@
<?php
namespace App\Models\Analytics;
use App\Models\Business;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AnalyticsEvent extends Model
{
public $timestamps = false;
const CREATED_AT = 'created_at';
const UPDATED_AT = null;
protected $fillable = [
'business_id',
'event_type',
'event_category',
'event_action',
'subject_id',
'subject_type',
'user_id',
'session_id',
'fingerprint',
'url',
'referrer',
'utm_source',
'utm_medium',
'utm_campaign',
'utm_content',
'utm_term',
'user_agent',
'device_type',
'browser',
'os',
'ip_address',
'country_code',
'metadata',
];
protected $casts = [
'metadata' => 'array',
'created_at' => 'datetime',
];
/**
* Boot the model and apply global scopes
*/
protected static function booted(): void
{
parent::booted();
// Auto-set business_id on creation
static::creating(function ($model) {
if (! $model->business_id) {
$model->business_id = currentBusinessId();
}
});
}
/**
* Relationships
*/
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* Scopes
*/
public function scopeForBusiness($query, $businessId)
{
return $query->where('business_id', $businessId);
}
public function scopeOfType($query, string $type)
{
return $query->where('event_type', $type);
}
public function scopeForSubject($query, string $type, int $id)
{
return $query->where('subject_type', $type)
->where('subject_id', $id);
}
public function scopeDateRange($query, $startDate, $endDate)
{
return $query->whereBetween('created_at', [$startDate, $endDate]);
}
}

View File

@@ -0,0 +1,134 @@
<?php
namespace App\Models\Analytics;
use App\Models\Business;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class BuyerEngagementScore extends Model
{
protected $fillable = [
'business_id',
'buyer_business_id',
'score',
'score_tier',
'recency_score',
'frequency_score',
'depth_score',
'intent_score',
'first_interaction_at',
'last_interaction_at',
'days_since_last_interaction',
'sessions_30d',
'page_views_30d',
'product_views_30d',
'total_orders',
'total_revenue',
'last_order_at',
'days_since_last_order',
'calculated_at',
];
protected $casts = [
'total_revenue' => 'decimal:2',
'last_interaction_at' => 'datetime',
'first_interaction_at' => 'datetime',
'last_order_at' => 'datetime',
'calculated_at' => 'datetime',
];
// Engagement trends
const TREND_INCREASING = 'increasing';
const TREND_STABLE = 'stable';
const TREND_DECLINING = 'declining';
const TREND_NEW = 'new';
protected static function booted(): void
{
parent::booted();
static::creating(function ($model) {
if (! $model->business_id) {
$model->business_id = currentBusinessId();
}
});
}
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function buyerBusiness(): BelongsTo
{
return $this->belongsTo(Business::class, 'buyer_business_id');
}
public function intentSignals(): HasMany
{
return $this->hasMany(IntentSignal::class, 'buyer_business_id', 'buyer_business_id')
->where('business_id', $this->business_id);
}
public function scopeForBusiness($query, $businessId)
{
return $query->where('business_id', $businessId);
}
public function scopeHighValue($query)
{
return $query->where('score', '>=', 70);
}
public function scopeAtRisk($query)
{
return $query->where('score_tier', 'at_risk')
->where('days_since_last_interaction', '>', 30);
}
public function scopeActive($query)
{
return $query->where('days_since_last_interaction', '<=', 30);
}
public function scopeByTier($query, string $tier)
{
return $query->where('score_tier', $tier);
}
public function calculateScore()
{
// Weighted scoring algorithm
$this->score = min(100, (
($this->recency_score * 0.25) +
($this->frequency_score * 0.25) +
($this->depth_score * 0.30) +
($this->intent_score * 0.20)
));
return $this->score;
}
public function updateDaysSinceLastInteraction()
{
if ($this->last_interaction_at) {
$this->days_since_last_interaction = now()->diffInDays($this->last_interaction_at);
}
}
public function isHighValue(): bool
{
return $this->score >= 70;
}
public function isAtRisk(): bool
{
return $this->score_tier === 'at_risk'
&& $this->days_since_last_interaction > 30;
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Models\Analytics;
use App\Models\Business;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ClickTracking extends Model
{
public $timestamps = false;
const CREATED_AT = 'clicked_at';
const UPDATED_AT = null;
protected $table = 'click_tracking';
protected $fillable = [
'business_id',
'user_id',
'session_id',
'element_type',
'element_id',
'element_label',
'url',
'page_url',
'clicked_at',
'metadata',
];
protected $casts = [
'clicked_at' => 'datetime',
'metadata' => 'array',
];
protected static function booted(): void
{
parent::booted();
static::creating(function ($model) {
if (! $model->business_id) {
$model->business_id = currentBusinessId();
}
});
}
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function scopeForBusiness($query, $businessId)
{
return $query->where('business_id', $businessId);
}
public function scopeForElement($query, string $type, int $id)
{
return $query->where('element_type', $type)
->where('element_id', $id);
}
public function scopeOnPage($query, string $pageUrl)
{
return $query->where('page_url', $pageUrl);
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Models\Analytics;
use App\Models\Business;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class EmailCampaign extends Model
{
use SoftDeletes;
protected $fillable = [
'business_id',
'name',
'subject',
'content',
'status',
'scheduled_at',
'sent_at',
'total_recipients',
'total_sent',
'total_delivered',
'total_opened',
'total_clicked',
'total_bounced',
'metadata',
];
protected $casts = [
'scheduled_at' => 'datetime',
'sent_at' => 'datetime',
'metadata' => 'array',
];
protected static function booted(): void
{
parent::booted();
static::creating(function ($model) {
if (! $model->business_id) {
$model->business_id = currentBusinessId();
}
});
}
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function interactions(): HasMany
{
return $this->hasMany(EmailInteraction::class, 'campaign_id');
}
public function scopeForBusiness($query, $businessId)
{
return $query->where('business_id', $businessId);
}
public function getOpenRateAttribute()
{
if ($this->total_delivered == 0) {
return 0;
}
return round(($this->total_opened / $this->total_delivered) * 100, 2);
}
public function getClickRateAttribute()
{
if ($this->total_delivered == 0) {
return 0;
}
return round(($this->total_clicked / $this->total_delivered) * 100, 2);
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Models\Analytics;
use App\Models\Business;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class EmailClick extends Model
{
public $timestamps = false;
const CREATED_AT = 'clicked_at';
const UPDATED_AT = null;
protected $fillable = [
'business_id',
'email_interaction_id',
'url',
'link_identifier',
'clicked_at',
];
protected $casts = [
'clicked_at' => 'datetime',
];
protected static function booted(): void
{
parent::booted();
static::creating(function ($model) {
if (! $model->business_id) {
$model->business_id = currentBusinessId();
}
});
}
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function emailInteraction(): BelongsTo
{
return $this->belongsTo(EmailInteraction::class);
}
public function scopeForBusiness($query, $businessId)
{
return $query->where('business_id', $businessId);
}
public function scopeForUrl($query, string $url)
{
return $query->where('url', $url);
}
}

View File

@@ -0,0 +1,157 @@
<?php
namespace App\Models\Analytics;
use App\Models\Business;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
class EmailInteraction extends Model
{
protected $fillable = [
'business_id',
'campaign_id',
'recipient_user_id',
'recipient_email',
'tracking_token',
'sent_at',
'delivered_at',
'bounced_at',
'bounce_reason',
'first_opened_at',
'last_opened_at',
'open_count',
'first_clicked_at',
'last_clicked_at',
'click_count',
'email_client',
'device_type',
'engagement_score',
'metadata',
];
protected $casts = [
'sent_at' => 'datetime',
'delivered_at' => 'datetime',
'bounced_at' => 'datetime',
'first_opened_at' => 'datetime',
'last_opened_at' => 'datetime',
'first_clicked_at' => 'datetime',
'last_clicked_at' => 'datetime',
'metadata' => 'array',
];
protected static function booted(): void
{
parent::booted();
static::creating(function ($model) {
if (! $model->business_id) {
$model->business_id = currentBusinessId();
}
if (! $model->tracking_token) {
$model->tracking_token = Str::random(64);
}
});
}
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function campaign(): BelongsTo
{
return $this->belongsTo(EmailCampaign::class, 'campaign_id');
}
public function recipientUser(): BelongsTo
{
return $this->belongsTo(User::class, 'recipient_user_id');
}
public function clicks(): HasMany
{
return $this->hasMany(EmailClick::class, 'email_interaction_id');
}
public function scopeForBusiness($query, $businessId)
{
return $query->where('business_id', $businessId);
}
public function recordOpen(?string $emailClient = null, ?string $deviceType = null)
{
$now = now();
if (! $this->first_opened_at) {
$this->first_opened_at = $now;
}
$this->last_opened_at = $now;
$this->open_count++;
if ($emailClient) {
$this->email_client = $emailClient;
}
if ($deviceType) {
$this->device_type = $deviceType;
}
$this->calculateEngagementScore();
$this->save();
$this->campaign->increment('total_opened');
}
public function recordClick(string $url, ?string $linkIdentifier = null)
{
$now = now();
if (! $this->first_clicked_at) {
$this->first_clicked_at = $now;
}
$this->last_clicked_at = $now;
$this->click_count++;
$this->calculateEngagementScore();
$this->save();
EmailClick::create([
'business_id' => $this->business_id,
'email_interaction_id' => $this->id,
'url' => $url,
'link_identifier' => $linkIdentifier,
'clicked_at' => $now,
]);
if ($this->click_count == 1) {
$this->campaign->increment('total_clicked');
}
}
protected function calculateEngagementScore()
{
$score = 0;
if ($this->open_count > 0) {
$score += 20;
}
if ($this->open_count > 2) {
$score += 15;
}
if ($this->click_count > 0) {
$score += 40;
}
if ($this->click_count > 1) {
$score += 25;
}
$this->engagement_score = min($score, 100);
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace App\Models\Analytics;
use App\Models\Business;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class IntentSignal extends Model
{
protected $fillable = [
'business_id',
'buyer_business_id',
'user_id',
'signal_type',
'signal_strength',
'subject_type',
'subject_id',
'session_id',
'context',
'detected_at',
];
protected $casts = [
'detected_at' => 'datetime',
'context' => 'array',
];
// Signal types
const TYPE_HIGH_ENGAGEMENT = 'high_engagement';
const TYPE_REPEAT_VIEWS = 'repeat_views';
const TYPE_PRICE_CHECK = 'price_check';
const TYPE_SPEC_DOWNLOAD = 'spec_download';
const TYPE_CART_ABANDON = 'cart_abandon';
const TYPE_EMAIL_CLICK = 'email_click';
const TYPE_SEARCH_PATTERN = 'search_pattern';
const TYPE_COMPETITOR_COMPARISON = 'competitor_comparison';
// Signal strengths
const STRENGTH_LOW = 10;
const STRENGTH_MEDIUM = 50;
const STRENGTH_HIGH = 75;
const STRENGTH_CRITICAL = 100;
protected static function booted(): void
{
parent::booted();
static::creating(function ($model) {
if (! $model->business_id) {
$model->business_id = currentBusinessId();
}
});
}
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function buyerBusiness(): BelongsTo
{
return $this->belongsTo(Business::class, 'buyer_business_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function scopeForBusiness($query, $businessId)
{
return $query->where('business_id', $businessId);
}
public function scopeHighIntent($query)
{
return $query->where('signal_strength', '>=', self::STRENGTH_HIGH);
}
public function scopeOfType($query, string $type)
{
return $query->where('signal_type', $type);
}
public function scopeForBuyer($query, int $buyerBusinessId)
{
return $query->where('buyer_business_id', $buyerBusinessId);
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace App\Models\Analytics;
use App\Models\Business;
use App\Models\Product;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ProductView extends Model
{
protected $fillable = [
'business_id',
'product_id',
'user_id',
'buyer_business_id',
'session_id',
'viewed_at',
'time_on_page',
'scroll_depth',
'zoomed_image',
'watched_video',
'downloaded_spec',
'added_to_cart',
'added_to_wishlist',
'source',
'referrer',
'utm_campaign',
'device_type',
];
protected $casts = [
'viewed_at' => 'datetime',
'zoomed_image' => 'boolean',
'watched_video' => 'boolean',
'downloaded_spec' => 'boolean',
'added_to_cart' => 'boolean',
'added_to_wishlist' => 'boolean',
];
protected static function booted(): void
{
parent::booted();
static::creating(function ($model) {
if (! $model->business_id) {
$model->business_id = currentBusinessId();
}
});
}
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function buyerBusiness(): BelongsTo
{
return $this->belongsTo(Business::class, 'buyer_business_id');
}
public function scopeForBusiness($query, $businessId)
{
return $query->where('business_id', $businessId);
}
public function scopeHighEngagement($query)
{
return $query->where(function ($q) {
$q->where('time_on_page', '>', 30)
->orWhere('zoomed_image', true)
->orWhere('watched_video', true)
->orWhere('downloaded_spec', true);
});
}
}

View File

@@ -0,0 +1,100 @@
<?php
namespace App\Models\Analytics;
use App\Models\Business;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UserSession extends Model
{
protected $fillable = [
'business_id',
'user_id',
'session_id',
'fingerprint',
'started_at',
'ended_at',
'last_activity_at',
'duration_seconds',
'page_views',
'product_views',
'cart_additions',
'checkout_initiated',
'order_completed',
'entry_url',
'exit_url',
'referrer',
'utm_source',
'utm_medium',
'utm_campaign',
'device_type',
'browser',
'os',
'country_code',
];
protected $casts = [
'started_at' => 'datetime',
'ended_at' => 'datetime',
'last_activity_at' => 'datetime',
'checkout_initiated' => 'boolean',
'order_completed' => 'boolean',
];
protected static function booted(): void
{
parent::booted();
static::creating(function ($model) {
if (! $model->business_id && function_exists('currentBusiness')) {
$business = currentBusiness();
if ($business) {
$model->business_id = $business->id;
}
}
});
}
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function scopeForBusiness($query, $businessId)
{
return $query->where('business_id', $businessId);
}
public function scopeActive($query)
{
return $query->whereNull('ended_at')
->where('last_activity_at', '>', now()->subMinutes(30));
}
public function scopeConverted($query)
{
return $query->where('order_completed', true);
}
public function endSession()
{
if (! $this->ended_at) {
$this->ended_at = now();
$this->duration_seconds = $this->started_at->diffInSeconds($this->ended_at);
$this->save();
}
}
public function updateActivity()
{
$this->last_activity_at = now();
$this->save();
}
}

70
app/Models/AuditLog.php Normal file
View File

@@ -0,0 +1,70 @@
<?php
namespace App\Models;
use OwenIt\Auditing\Models\Audit;
/**
* AuditLog Model
*
* Wrapper around the Laravel Auditing package's Audit model
* with business-specific scopes and relationships for multi-tenancy.
*/
class AuditLog extends Audit
{
/**
* Scope to filter audits for a specific business
*
* Since the audits table doesn't have a business_id column,
* we filter by the auditable models that belong to the business.
* For now, we'll show all audits - this can be refined later
* when implementing proper multi-tenant audit filtering.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param int $businessId
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeForBusiness($query, $businessId)
{
// TODO: Implement proper business-scoped filtering
// This would require joining with auditable models to check business ownership
// For now, return all audits (will be implemented when audit system is fully configured)
return $query;
}
/**
* Scope to filter by event type
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param string $event
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeByEvent($query, $event)
{
return $query->where('event', $event);
}
/**
* Scope to filter by auditable type (resource type)
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param string $type
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeByType($query, $type)
{
return $query->where('auditable_type', $type);
}
/**
* Scope to filter audits for a specific user
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param int $userId
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeForUser($query, $userId)
{
return $query->where('user_id', $userId);
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Models;
use App\Traits\BelongsToBusinessDirectly;
use App\Traits\HasHashid;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -12,7 +13,7 @@ use Str;
class Brand extends Model
{
use BelongsToBusinessDirectly, HasFactory, SoftDeletes;
use BelongsToBusinessDirectly, HasFactory, HasHashid, SoftDeletes;
// Product Categories that can be organized under brands
public const PRODUCT_CATEGORIES = [
@@ -32,21 +33,33 @@ class Brand extends Model
'business_id',
// Brand Identity
'hashid',
'name',
'slug',
'sku_prefix', // SKU prefix for products
'description',
'long_description',
'tagline',
// Branding Assets
'logo_path',
'banner_path',
'website_url',
'colors', // JSON: hex color codes for theming
// Physical Address
'address',
'unit_number',
'city',
'state',
'zip_code',
'phone',
// Social Media
'instagram_handle',
'facebook_url',
'twitter_handle',
'youtube_url',
// Display Settings
'is_active',
@@ -162,14 +175,6 @@ class Brand extends Model
->get();
}
/**
* Get route key (slug for URLs)
*/
public function getRouteKeyName(): string
{
return 'slug';
}
/**
* Generate slug from name
*/
@@ -178,6 +183,15 @@ class Brand extends Model
return Str::slug($this->name);
}
/**
* Get the storage path for this brand's assets
* Format: {hashid}/ (e.g., "52kn5/")
*/
public function getStoragePath(): string
{
return $this->hashid.'/';
}
/**
* Check if brand has a logo
*/

114
app/Models/BrandKit.php Normal file
View File

@@ -0,0 +1,114 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class BrandKit extends Model
{
use HasFactory;
protected $fillable = [
// Owner (Business or Brand)
'owner_id',
'owner_type',
// Basic Info
'name',
'description',
// Logo Assets
'logo_primary_path',
'logo_secondary_path',
'logo_icon_path',
'logo_white_path',
'logo_black_path',
// Brand Guidelines
'colors', // JSON
'fonts', // JSON
'voice_description',
'tone_guidelines', // JSON
'messaging_guidelines', // JSON
'logo_usage_rules',
'color_usage_rules',
'dos_and_donts',
// Metadata
'is_active',
'is_default',
'sort_order',
];
protected $casts = [
'colors' => 'array',
'fonts' => 'array',
'tone_guidelines' => 'array',
'messaging_guidelines' => 'array',
'is_active' => 'boolean',
'is_default' => 'boolean',
'sort_order' => 'integer',
];
// Relationships
/**
* Polymorphic owner (Business or Brand)
*/
public function owner(): MorphTo
{
return $this->morphTo();
}
// Helper Methods
/**
* Get primary color
*/
public function getPrimaryColor(): string
{
$colors = $this->colors ?? [];
return $colors['primary'] ?? '#000000';
}
/**
* Get all brand colors
*/
public function getAllColors(): array
{
return $this->colors ?? [
'primary' => '#000000',
'secondary' => '#666666',
'accent' => '#0066cc',
'text' => '#333333',
'background' => '#ffffff',
];
}
/**
* Get logo URL (primary by default)
*/
public function getLogoUrl(?string $variant = 'primary'): ?string
{
$column = "logo_{$variant}_path";
if (! $this->$column) {
return null;
}
return asset('storage/'.$this->$column);
}
/**
* Check if kit has a logo variant
*/
public function hasLogo(string $variant = 'primary'): bool
{
$column = "logo_{$variant}_path";
return ! empty($this->$column) && \Storage::disk('public')->exists($this->$column);
}
}

151
app/Models/BrandMedia.php Normal file
View File

@@ -0,0 +1,151 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class BrandMedia extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'brand_id',
'uploaded_by_user_id',
// File Information
'file_path',
'file_name',
'file_type',
'file_extension',
'file_size',
'width',
'height',
// Categorization
'media_type', // logo, product_image, banner, social_media, document
'category',
'tags', // JSON
// Metadata
'title',
'description',
'alt_text',
'metadata', // JSON
// Usage
'is_public',
'is_approved',
'approved_at',
'approved_by_user_id',
// Organization
'sort_order',
'is_featured',
];
protected $casts = [
'tags' => 'array',
'metadata' => 'array',
'is_public' => 'boolean',
'is_approved' => 'boolean',
'approved_at' => 'datetime',
'sort_order' => 'integer',
'is_featured' => 'boolean',
'file_size' => 'integer',
'width' => 'integer',
'height' => 'integer',
];
// Relationships
public function brand(): BelongsTo
{
return $this->belongsTo(Brand::class);
}
public function uploadedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'uploaded_by_user_id');
}
public function approvedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'approved_by_user_id');
}
// Scopes
public function scopeApproved($query)
{
return $query->where('is_approved', true);
}
public function scopeByType($query, string $type)
{
return $query->where('media_type', $type);
}
public function scopeByCategory($query, string $category)
{
return $query->where('category', $category);
}
public function scopePublic($query)
{
return $query->where('is_public', true);
}
// Helper Methods
/**
* Get public URL for the media file
*/
public function getUrl(): string
{
return asset('storage/'.$this->file_path);
}
/**
* Get file size in human-readable format
*/
public function getFileSizeHuman(): string
{
$bytes = $this->file_size;
$units = ['B', 'KB', 'MB', 'GB'];
$i = 0;
while ($bytes >= 1024 && $i < 3) {
$bytes /= 1024;
$i++;
}
return round($bytes, 2).' '.$units[$i];
}
/**
* Check if file is an image
*/
public function isImage(): bool
{
return in_array($this->file_extension, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg']);
}
/**
* Check if file is a video
*/
public function isVideo(): bool
{
return in_array($this->file_extension, ['mp4', 'mov', 'avi', 'webm']);
}
/**
* Check if file is a document
*/
public function isDocument(): bool
{
return in_array($this->file_extension, ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx']);
}
}

171
app/Models/Broadcast.php Normal file
View File

@@ -0,0 +1,171 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Broadcast extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'business_id',
'created_by_user_id',
'name',
'description',
'type',
'channel',
'template_id',
'subject',
'content',
'audience_ids',
'segment_rules',
'include_all',
'exclude_audience_ids',
'scheduled_at',
'timezone',
'recurring_pattern',
'recurring_ends_at',
'status',
'started_sending_at',
'finished_sending_at',
'total_recipients',
'total_sent',
'total_delivered',
'total_failed',
'total_opened',
'total_clicked',
'total_unsubscribed',
'track_opens',
'track_clicks',
'send_rate_limit',
'metadata',
];
protected $casts = [
'audience_ids' => 'array',
'segment_rules' => 'array',
'exclude_audience_ids' => 'array',
'include_all' => 'boolean',
'scheduled_at' => 'datetime',
'recurring_ends_at' => 'datetime',
'started_sending_at' => 'datetime',
'finished_sending_at' => 'datetime',
'recurring_pattern' => 'array',
'track_opens' => 'boolean',
'track_clicks' => 'boolean',
'metadata' => 'array',
];
// Relationships
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function createdBy(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_user_id');
}
public function template(): BelongsTo
{
return $this->belongsTo(MarketingTemplate::class, 'template_id');
}
public function recipients(): HasMany
{
return $this->hasMany(BroadcastRecipient::class);
}
public function events(): HasMany
{
return $this->hasMany(BroadcastEvent::class);
}
public function audiences()
{
if (! $this->audience_ids) {
return collect();
}
return MarketingAudience::whereIn('id', $this->audience_ids)->get();
}
// Scopes
public function scopeActive($query)
{
return $query->whereIn('status', ['scheduled', 'sending']);
}
public function scopeScheduled($query)
{
return $query->where('status', 'scheduled')
->where('scheduled_at', '<=', now());
}
// Helpers
public function isDraft(): bool
{
return $this->status === 'draft';
}
public function isScheduled(): bool
{
return $this->status === 'scheduled';
}
public function isSending(): bool
{
return $this->status === 'sending';
}
public function isSent(): bool
{
return $this->status === 'sent';
}
public function canBeSent(): bool
{
return in_array($this->status, ['draft', 'scheduled', 'paused']);
}
public function canBeCancelled(): bool
{
return in_array($this->status, ['draft', 'scheduled', 'paused']);
}
public function getOpenRate(): float
{
if ($this->total_delivered === 0) {
return 0;
}
return round(($this->total_opened / $this->total_delivered) * 100, 2);
}
public function getClickRate(): float
{
if ($this->total_delivered === 0) {
return 0;
}
return round(($this->total_clicked / $this->total_delivered) * 100, 2);
}
public function getDeliveryRate(): float
{
if ($this->total_sent === 0) {
return 0;
}
return round(($this->total_delivered / $this->total_sent) * 100, 2);
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BroadcastEvent extends Model
{
use HasFactory;
public $timestamps = false;
protected $fillable = [
'broadcast_id',
'user_id',
'event',
'link_url',
'user_agent',
'ip_address',
'device_type',
'metadata',
'occurred_at',
];
protected $casts = [
'metadata' => 'array',
'occurred_at' => 'datetime',
];
// Relationships
public function broadcast(): BelongsTo
{
return $this->belongsTo(Broadcast::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
// Helpers
public function isOpen(): bool
{
return $this->event === 'opened';
}
public function isClick(): bool
{
return $this->event === 'clicked';
}
public function isUnsubscribe(): bool
{
return $this->event === 'unsubscribed';
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BroadcastRecipient extends Model
{
use HasFactory;
protected $fillable = [
'broadcast_id',
'user_id',
'status',
'queued_at',
'sent_at',
'delivered_at',
'failed_at',
'error_message',
'error_code',
'provider_message_id',
'provider_response',
];
protected $casts = [
'queued_at' => 'datetime',
'sent_at' => 'datetime',
'delivered_at' => 'datetime',
'failed_at' => 'datetime',
'provider_response' => 'array',
];
// Relationships
public function broadcast(): BelongsTo
{
return $this->belongsTo(Broadcast::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
// Helpers
public function markAsQueued(): void
{
$this->update([
'status' => 'queued',
'queued_at' => now(),
]);
}
public function markAsSent(?string $providerId = null): void
{
$this->update([
'status' => 'sent',
'sent_at' => now(),
'provider_message_id' => $providerId,
]);
}
public function markAsDelivered(): void
{
$this->update([
'status' => 'delivered',
'delivered_at' => now(),
]);
}
public function markAsFailed(string $error, ?string $code = null): void
{
$this->update([
'status' => 'failed',
'failed_at' => now(),
'error_message' => $error,
'error_code' => $code,
]);
}
}

View File

@@ -172,6 +172,39 @@ class Business extends Model implements AuditableContract
'approved_at',
'approved_by',
'notes',
// Order Settings
'separate_orders_by_brand',
'auto_increment_order_ids',
'show_mark_as_paid',
'display_crm_license_on_orders',
'order_minimum',
'default_shipping_charge',
'free_shipping_minimum',
'order_disclaimer',
'order_invoice_footer',
'prevent_order_editing',
'az_require_patient_count',
'az_require_allotment_verification',
// Invoice Settings
'invoice_payable_company_name',
'invoice_payable_address',
'invoice_payable_city',
'invoice_payable_state',
'invoice_payable_zipcode',
// Notification Settings
'new_order_email_notifications',
'new_order_only_when_no_sales_rep',
'new_order_do_not_send_to_admins',
'order_accepted_email_notifications',
'enable_shipped_emails_for_sales_reps',
'platform_inquiry_email_notifications',
'enable_manual_order_email_notifications',
'manual_order_emails_internal_only',
'low_inventory_email_notifications',
'certified_seller_status_email_notifications',
];
protected $casts = [
@@ -186,6 +219,22 @@ class Business extends Model implements AuditableContract
'credit_limit' => 'decimal:2',
'tax_rate' => 'decimal:2',
'tax_exempt' => 'boolean',
// Order Settings
'separate_orders_by_brand' => 'boolean',
'auto_increment_order_ids' => 'boolean',
'show_mark_as_paid' => 'boolean',
'display_crm_license_on_orders' => 'boolean',
'order_minimum' => 'decimal:2',
'default_shipping_charge' => 'decimal:2',
'free_shipping_minimum' => 'decimal:2',
'az_require_patient_count' => 'boolean',
'az_require_allotment_verification' => 'boolean',
// Notification Settings
'new_order_only_when_no_sales_rep' => 'boolean',
'new_order_do_not_send_to_admins' => 'boolean',
'enable_shipped_emails_for_sales_reps' => 'boolean',
'enable_manual_order_email_notifications' => 'boolean',
'manual_order_emails_internal_only' => 'boolean',
];
// LeafLink-aligned Relationships

View File

@@ -3,6 +3,7 @@
namespace App\Models;
use App\Traits\BelongsToBusinessDirectly;
use App\Traits\HasHashid;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -13,10 +14,12 @@ use Illuminate\Support\Str;
class Component extends Model
{
use BelongsToBusinessDirectly, HasFactory, SoftDeletes;
use BelongsToBusinessDirectly, HasFactory, HasHashid, SoftDeletes;
protected $fillable = [
'hashid',
'business_id',
'category_id',
'name',
'slug',
'sku',
@@ -70,6 +73,14 @@ class Component extends Model
return $this->belongsTo(Business::class);
}
/**
* Component belongs to a category
*/
public function category(): BelongsTo
{
return $this->belongsTo(ComponentCategory::class, 'category_id');
}
/**
* Products that use this component in their BOM
*/

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class ComponentCategory extends Model
{
use HasFactory;
protected $fillable = [
'business_id',
'name',
'description',
'slug',
'sort_order',
'parent_id',
'is_active',
];
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function components(): HasMany
{
return $this->hasMany(Component::class, 'category_id');
}
public function parent(): BelongsTo
{
return $this->belongsTo(ComponentCategory::class, 'parent_id');
}
public function children(): HasMany
{
return $this->hasMany(ComponentCategory::class, 'parent_id');
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Models;
use App\Traits\BelongsToBusinessDirectly;
use App\Traits\HasHashid;
use DateTime;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -12,9 +13,9 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class Contact extends Model
{
use BelongsToBusinessDirectly, HasFactory, SoftDeletes;
use BelongsToBusinessDirectly, HasFactory, HasHashid, SoftDeletes;
// Contact Types for Cannabis Business (LeafLink-aligned)
// Contact Types for Cannabis Business
public const CONTACT_TYPES = [
'primary' => 'Primary Contact',
'owner' => 'Owner/Executive',
@@ -42,6 +43,8 @@ class Contact extends Model
];
protected $fillable = [
'hashid',
// Ownership
'business_id',
'location_id', // Optional - can be business-wide or location-specific

104
app/Models/Conversion.php Normal file
View File

@@ -0,0 +1,104 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
/**
* Conversion Model
*
* Represents a manufacturing conversion process where input materials/batches
* are transformed into output products/batches.
*
* Examples:
* - hash_wash: Converting raw material into washed hash
* - trim_to_extract: Converting trim into concentrate
* - flower_to_preroll: Converting flower into pre-rolls
*/
class Conversion extends Model
{
use HasFactory;
protected $fillable = [
'business_id',
'conversion_type',
'operator_id',
'conversion_date',
'notes',
'status',
'input_weight',
'output_weight',
'yield_percentage',
'metadata',
];
protected $casts = [
'conversion_date' => 'datetime',
'metadata' => 'array',
'input_weight' => 'decimal:2',
'output_weight' => 'decimal:2',
'yield_percentage' => 'decimal:2',
];
/**
* Get the business that owns the conversion
*/
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
/**
* Get the operator (user) who performed the conversion
*/
public function operator(): BelongsTo
{
return $this->belongsTo(User::class, 'operator_id');
}
/**
* Get the input batches used in this conversion
*/
public function inputBatches(): BelongsToMany
{
return $this->belongsToMany(Batch::class, 'conversion_inputs')
->withPivot(['quantity', 'unit', 'notes'])
->withTimestamps();
}
/**
* Get the batch created from this conversion
*/
public function batchCreated(): HasOne
{
return $this->hasOne(Batch::class, 'conversion_id');
}
/**
* Scope to filter by conversion type
*/
public function scopeOfType($query, string $type)
{
return $query->where('conversion_type', $type);
}
/**
* Scope to filter by business
*/
public function scopeForBusiness($query, int $businessId)
{
return $query->where('business_id', $businessId);
}
/**
* Get Stage 1 data from metadata
*/
public function getStage1Data(): ?array
{
return $this->metadata['stage_1'] ?? null;
}
}

View File

@@ -14,7 +14,7 @@ class Location extends Model
{
use BelongsToBusinessDirectly, HasFactory, SoftDeletes;
// Location Types (LeafLink Facilities)
// Location Types
public const LOCATION_TYPES = [
'dispensary' => 'Retail Dispensary',
'cultivation' => 'Cultivation Facility',
@@ -219,7 +219,7 @@ class Location extends Model
?? $this->addresses()->where('type', 'physical')->first();
}
// Archive/Transfer (LeafLink pattern)
// Archive/Transfer
public function archive(?string $reason = null)
{
$this->update([

View File

@@ -0,0 +1,231 @@
<?php
namespace App\Models\Marketing;
use App\Models\Brand;
use App\Models\Business;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Template extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'business_id',
'category_id',
'created_by',
'updated_by',
'name',
'description',
'thumbnail',
'design_json',
'mjml_content',
'html_content',
'plain_text',
'is_system_template',
'is_public',
'is_premium',
'template_type',
'tags',
'usage_count',
'usage_limit',
'usage_period',
'last_used_at',
'version',
];
protected $casts = [
'design_json' => 'array',
'tags' => 'array',
'is_system_template' => 'boolean',
'is_public' => 'boolean',
'is_premium' => 'boolean',
'last_used_at' => 'datetime',
];
protected $appends = ['is_editable'];
// Relationships
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function category(): BelongsTo
{
return $this->belongsTo(TemplateCategory::class, 'category_id');
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function updater(): BelongsTo
{
return $this->belongsTo(User::class, 'updated_by');
}
public function versions(): HasMany
{
return $this->hasMany(TemplateVersion::class)->orderBy('version_number', 'desc');
}
public function analytics(): HasMany
{
return $this->hasMany(TemplateAnalytics::class);
}
public function brands(): BelongsToMany
{
return $this->belongsToMany(Brand::class, 'brand_templates')
->withPivot(['is_favorite', 'usage_count',
'usage_limit',
'usage_period', 'last_used_at', 'added_by'])
->withTimestamps();
}
public function broadcasts(): HasMany
{
return $this->hasMany(Broadcast::class, 'template_id');
}
// Scopes
public function scopeForBusiness($query, $businessId)
{
return $query->where(function ($q) use ($businessId) {
$q->where('business_id', $businessId)
->orWhere('is_system_template', true);
});
}
public function scopeSystemTemplates($query)
{
return $query->where('is_system_template', true);
}
public function scopeUserTemplates($query, $businessId)
{
return $query->where('business_id', $businessId)
->where('is_system_template', false);
}
public function scopeByType($query, string $type)
{
return $query->where('template_type', $type);
}
public function scopeByCategory($query, $categoryId)
{
return $query->where('category_id', $categoryId);
}
public function scopeRecent($query)
{
return $query->orderBy('created_at', 'desc');
}
public function scopePopular($query)
{
return $query->orderBy('usage_count',
'usage_limit',
'usage_period', 'desc');
}
public function scopeWithTags($query, array $tags)
{
return $query->where(function ($q) use ($tags) {
foreach ($tags as $tag) {
$q->orWhereJsonContains('tags', $tag);
}
});
}
// Helper Methods
public function getIsEditableAttribute(): bool
{
// System templates are read-only
if ($this->is_system_template) {
return false;
}
// User can edit their own business templates
return $this->business_id === currentBusiness()?->id;
}
public function canBeDeleted(): bool
{
// System templates cannot be deleted
if ($this->is_system_template) {
return false;
}
// Check if template is in use
if ($this->usage_count > 0 || $this->broadcasts()->exists()) {
return false;
}
return true;
}
public function incrementUsage(): void
{
$this->increment('usage_count');
$this->update(['last_used_at' => now()]);
}
public function createVersion(?string $notes = null): TemplateVersion
{
return $this->versions()->create([
'version_number' => $this->version,
'version_name' => "Version {$this->version}",
'change_notes' => $notes,
'design_json' => $this->design_json,
'mjml_content' => $this->mjml_content,
'html_content' => $this->html_content,
'created_by' => auth()->id(),
]);
}
public function restoreVersion(TemplateVersion $version): void
{
$this->update([
'design_json' => $version->design_json,
'mjml_content' => $version->mjml_content,
'html_content' => $version->html_content,
'version' => $this->version + 1,
]);
$this->createVersion("Restored from Version {$version->version_number}");
}
public function duplicate(string $newName): self
{
$duplicate = $this->replicate();
$duplicate->name = $newName;
$duplicate->is_system_template = false;
$duplicate->usage_count = 0;
$duplicate->last_used_at = null;
$duplicate->created_by = auth()->id();
$duplicate->save();
return $duplicate;
}
public function getAnalyticsForBusiness($businessId, $brandId = null): ?TemplateAnalytics
{
return $this->analytics()
->where('business_id', $businessId)
->where('brand_id', $brandId)
->first();
}
}

View File

@@ -0,0 +1,96 @@
<?php
namespace App\Models\Marketing;
use App\Models\Brand;
use App\Models\Business;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TemplateAnalytics extends Model
{
use HasFactory;
protected $fillable = [
'template_id',
'business_id',
'brand_id',
'times_used',
'total_sends',
'total_opens',
'total_clicks',
'total_bounces',
'total_unsubscribes',
'avg_open_rate',
'avg_click_rate',
'avg_bounce_rate',
'first_used_at',
'last_used_at',
'best_subject_line',
'best_open_rate',
];
protected $casts = [
'first_used_at' => 'datetime',
'last_used_at' => 'datetime',
];
public function template(): BelongsTo
{
return $this->belongsTo(Template::class);
}
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function brand(): BelongsTo
{
return $this->belongsTo(Brand::class);
}
public function updateFromBroadcast(Broadcast $broadcast): void
{
$this->increment('times_used');
$this->increment('total_sends', $broadcast->sent_count);
// Update engagement metrics
$opens = $broadcast->interactions()->where('status', 'opened')->count();
$clicks = $broadcast->interactions()->where('status', 'clicked')->count();
$bounces = $broadcast->interactions()->where('status', 'bounced')->count();
$this->increment('total_opens', $opens);
$this->increment('total_clicks', $clicks);
$this->increment('total_bounces', $bounces);
// Recalculate averages
$this->updateAverages();
// Update best performing
$openRate = $broadcast->open_rate;
if ($openRate > $this->best_open_rate) {
$this->update([
'best_open_rate' => $openRate,
'best_subject_line' => $broadcast->subject,
]);
}
$this->update([
'last_used_at' => now(),
'first_used_at' => $this->first_used_at ?? now(),
]);
}
protected function updateAverages(): void
{
if ($this->total_sends > 0) {
$this->update([
'avg_open_rate' => round(($this->total_opens / $this->total_sends) * 100, 2),
'avg_click_rate' => round(($this->total_clicks / $this->total_sends) * 100, 2),
'avg_bounce_rate' => round(($this->total_bounces / $this->total_sends) * 100, 2),
]);
}
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Models\Marketing;
use App\Models\Business;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class TemplateBlock extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'business_id',
'created_by',
'name',
'description',
'thumbnail',
'block_type',
'design_json',
'usage_count',
];
protected $casts = [
'design_json' => 'array',
];
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function scopeForBusiness($query, $businessId)
{
return $query->where('business_id', $businessId);
}
public function scopeByType($query, string $type)
{
return $query->where('block_type', $type);
}
public function incrementUsage(): void
{
$this->increment('usage_count');
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Models\Marketing;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class TemplateCategory extends Model
{
use HasFactory;
protected $fillable = [
'name',
'slug',
'description',
'icon',
'color',
'sort_order',
];
public function templates(): HasMany
{
return $this->hasMany(Template::class, 'category_id');
}
public function scopeSorted($query)
{
return $query->orderBy('sort_order');
}
public function getTemplateCountAttribute(): int
{
return $this->templates()->count();
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Models\Marketing;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TemplateVersion extends Model
{
use HasFactory;
protected $fillable = [
'template_id',
'created_by',
'version_number',
'version_name',
'change_notes',
'design_json',
'mjml_content',
'html_content',
];
protected $casts = [
'design_json' => 'array',
];
public function template(): BelongsTo
{
return $this->belongsTo(Template::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class PaymentMethod extends Model
{
use SoftDeletes;
protected $fillable = [
'business_id',
'card_brand',
'last4',
'exp_month',
'exp_year',
'is_active',
'billing_name',
'billing_address_line1',
'billing_address_line2',
'billing_city',
'billing_state',
'billing_postal_code',
'billing_country',
'stripe_payment_method_id',
];
protected $casts = [
'is_active' => 'boolean',
'exp_month' => 'integer',
'exp_year' => 'integer',
];
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function isExpired(): bool
{
$now = now();
return $now->year > $this->exp_year ||
($now->year === $this->exp_year && $now->month > $this->exp_month);
}
}

View File

@@ -0,0 +1,208 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PermissionAuditLog extends Model
{
// Action types
const ACTION_GRANTED = 'granted';
const ACTION_REVOKED = 'revoked';
const ACTION_ROLE_CHANGED = 'role_changed';
const ACTION_BULK_UPDATE = 'bulk_update';
protected $fillable = [
'business_id',
'actor_user_id',
'target_user_id',
'action',
'permission',
'old_role_template',
'new_role_template',
'permissions_before',
'permissions_after',
'is_critical',
'expires_at',
'reason',
'ip_address',
'user_agent',
];
protected $casts = [
'permissions_before' => 'array',
'permissions_after' => 'array',
'is_critical' => 'boolean',
'expires_at' => 'datetime',
'created_at' => 'datetime',
];
// Timestamps
public $timestamps = false; // We only use created_at
const CREATED_AT = 'created_at';
const UPDATED_AT = null;
/**
* Boot the model
*/
protected static function boot()
{
parent::boot();
static::creating(function ($model) {
$model->created_at = now();
});
}
/**
* Relationships
*/
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function actor(): BelongsTo
{
return $this->belongsTo(User::class, 'actor_user_id');
}
public function targetUser(): BelongsTo
{
return $this->belongsTo(User::class, 'target_user_id');
}
/**
* Scopes
*/
public function scopeForBusiness($query, int $businessId)
{
return $query->where('business_id', $businessId);
}
public function scopeForUser($query, int $userId)
{
return $query->where('target_user_id', $userId);
}
public function scopeCritical($query)
{
return $query->where('is_critical', true);
}
public function scopeExpired($query)
{
return $query->where('is_critical', false)
->whereNotNull('expires_at')
->where('expires_at', '<=', now());
}
public function scopeRecent($query, int $days = 30)
{
return $query->where('created_at', '>=', now()->subDays($days));
}
public function scopeByAction($query, string $action)
{
return $query->where('action', $action);
}
/**
* Helper methods
*/
/**
* Get formatted action name for display
*/
public function getActionNameAttribute(): string
{
return match ($this->action) {
self::ACTION_GRANTED => 'Permission Granted',
self::ACTION_REVOKED => 'Permission Revoked',
self::ACTION_ROLE_CHANGED => 'Role Changed',
self::ACTION_BULK_UPDATE => 'Permissions Updated',
default => ucfirst($this->action),
};
}
/**
* Get human-readable summary of the change
*/
public function getSummaryAttribute(): string
{
$actorName = $this->actor ? $this->actor->name : 'System';
$targetName = $this->targetUser ? $this->targetUser->name : 'Unknown User';
return match ($this->action) {
self::ACTION_GRANTED => "{$actorName} granted '{$this->permission}' to {$targetName}",
self::ACTION_REVOKED => "{$actorName} revoked '{$this->permission}' from {$targetName}",
self::ACTION_ROLE_CHANGED => "{$actorName} changed {$targetName}'s role from '{$this->old_role_template}' to '{$this->new_role_template}'",
self::ACTION_BULK_UPDATE => "{$actorName} updated permissions for {$targetName}",
default => "{$actorName} performed {$this->action} on {$targetName}",
};
}
/**
* Get list of permissions that were added
*/
public function getAddedPermissionsAttribute(): array
{
$before = $this->permissions_before ?? [];
$after = $this->permissions_after ?? [];
return array_values(array_diff($after, $before));
}
/**
* Get list of permissions that were removed
*/
public function getRemovedPermissionsAttribute(): array
{
$before = $this->permissions_before ?? [];
$after = $this->permissions_after ?? [];
return array_values(array_diff($before, $after));
}
/**
* Check if this log entry is expired and can be deleted
*/
public function isExpired(): bool
{
if ($this->is_critical) {
return false;
}
if (! $this->expires_at) {
return false;
}
return $this->expires_at->isPast();
}
/**
* Get retention status
*/
public function getRetentionStatusAttribute(): string
{
if ($this->is_critical) {
return 'Kept forever (critical)';
}
if (! $this->expires_at) {
return 'No expiration set';
}
if ($this->expires_at->isPast()) {
return 'Expired';
}
return 'Expires '.$this->expires_at->diffForHumans();
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Models;
use App\Traits\BelongsToBusinessViaBrand;
use App\Traits\HasHashid;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -14,155 +15,70 @@ use OwenIt\Auditing\Contracts\Auditable;
class Product extends Model implements Auditable
{
use BelongsToBusinessViaBrand, HasFactory, \OwenIt\Auditing\Auditable, SoftDeletes;
use BelongsToBusinessViaBrand, HasFactory, HasHashid, \OwenIt\Auditing\Auditable, SoftDeletes;
protected $fillable = [
// Foreign Keys
'hashid',
'brand_id',
'strain_id',
'parent_product_id',
'packaging_id',
'unit_id',
// Product Identity
'category_id',
'name',
'slug',
'sku',
'barcode',
'description',
'long_description',
// Product Type & Classification
'type',
'category',
'product_line',
'product_link',
'creatives',
// BOM Flags
'is_assembly',
'is_raw_material',
// Configuration Flags
'has_varieties',
'sell_multiples',
'fractional_quantities',
'allow_sample',
'is_fpr',
'is_sellable',
// Pricing
'wholesale_price',
'msrp',
'msrp_price',
'cost_per_unit',
'price_unit',
// Packaging & Units
'net_weight',
'weight_unit',
'units_per_case',
'is_case',
'cased_qty',
'is_box',
'boxed_qty',
// Cannabis-specific
'thc_percentage',
'cbd_percentage',
'thc_content_mg',
'cbd_content_mg',
'strain_value',
'ingredients',
'effects',
'dosage_guidelines',
// Inventory & Status
'quantity_on_hand',
'quantity_allocated',
'reorder_point',
'min_order_qty',
'max_order_qty',
'low_stock_threshold',
'low_stock_alert_enabled',
'sync_bamboo',
'is_active',
'is_featured',
'show_inventory_to_buyers',
'status',
// Compliance & Tracking
'metrc_id',
'license_number',
'arz_total_weight',
'arz_usable_mmj',
'harvest_date',
'package_date',
'test_date',
'launch_date',
// Display & SEO
'sort_order',
'brand_display_order',
'image_path',
'meta_title',
'meta_description',
];
protected $casts = [
// Pricing
'wholesale_price' => 'decimal:2',
'msrp' => 'decimal:2',
'msrp_price' => 'decimal:2',
'cost_per_unit' => 'decimal:2',
// Measurements
'net_weight' => 'decimal:3',
'strain_value' => 'decimal:2',
'arz_total_weight' => 'decimal:3',
'arz_usable_mmj' => 'decimal:3',
// Cannabis
'thc_percentage' => 'decimal:2',
'cbd_percentage' => 'decimal:2',
'thc_content_mg' => 'decimal:2',
'cbd_content_mg' => 'decimal:2',
// Inventory
'quantity_on_hand' => 'integer',
'quantity_allocated' => 'integer',
'reorder_point' => 'integer',
'min_order_qty' => 'integer',
'max_order_qty' => 'integer',
'low_stock_threshold' => 'integer',
// Packaging
'units_per_case' => 'integer',
'cased_qty' => 'integer',
'boxed_qty' => 'integer',
'brand_display_order' => 'integer',
'sort_order' => 'integer',
// Booleans
'is_assembly' => 'boolean',
'is_raw_material' => 'boolean',
'has_varieties' => 'boolean',
'sell_multiples' => 'boolean',
'fractional_quantities' => 'boolean',
'allow_sample' => 'boolean',
'is_fpr' => 'boolean',
'is_sellable' => 'boolean',
'is_case' => 'boolean',
'is_box' => 'boolean',
'is_active' => 'boolean',
'is_featured' => 'boolean',
'show_inventory_to_buyers' => 'boolean',
'low_stock_alert_enabled' => 'boolean',
'sync_bamboo' => 'boolean',
// Dates
'sort_order' => 'integer',
'harvest_date' => 'date',
'package_date' => 'date',
'test_date' => 'date',
'launch_date' => 'date',
];
// Audit configuration - exclude timestamps and system-managed fields
@@ -194,24 +110,14 @@ class Product extends Model implements Auditable
return $this->belongsTo(Brand::class);
}
public function productLine(): BelongsTo
{
return $this->belongsTo(ProductLine::class);
}
public function strain(): BelongsTo
{
return $this->belongsTo(Strain::class);
}
public function packaging(): BelongsTo
public function category(): BelongsTo
{
return $this->belongsTo(ProductPackaging::class, 'packaging_id');
}
public function unit(): BelongsTo
{
return $this->belongsTo(Unit::class);
return $this->belongsTo(ProductCategory::class, 'category_id');
}
public function parent(): BelongsTo
@@ -226,7 +132,7 @@ class Product extends Model implements Auditable
public function images(): HasMany
{
return $this->hasMany(ProductImage::class)->orderBy('sort_order');
return $this->hasMany(ProductImage::class)->orderBy('order');
}
public function primaryImage(): HasMany

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class ProductCategory extends Model
{
use HasFactory;
protected $fillable = [
'business_id',
'name',
'description',
'slug',
'sort_order',
'parent_id',
'is_active',
];
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function products(): HasMany
{
return $this->hasMany(Product::class, 'category_id');
}
public function parent(): BelongsTo
{
return $this->belongsTo(ProductCategory::class, 'parent_id');
}
public function children(): HasMany
{
return $this->hasMany(ProductCategory::class, 'parent_id');
}
}

View File

@@ -26,4 +26,16 @@ class ProductImage extends Model
{
return $this->belongsTo(Product::class);
}
/**
* Get public URL for the product image
*/
public function getUrl(): ?string
{
if (! $this->path) {
return null;
}
return asset('storage/'.$this->path);
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Subscription extends Model
{
use SoftDeletes;
protected $fillable = [
'business_id',
'plan_id',
'plan_name',
'plan_price',
'status',
'current_period_start',
'current_period_end',
'trial_ends_at',
'scheduled_plan_id',
'scheduled_plan_name',
'scheduled_plan_price',
'scheduled_change_date',
'default_payment_method_id',
];
protected $casts = [
'plan_price' => 'decimal:2',
'scheduled_plan_price' => 'decimal:2',
'current_period_start' => 'date',
'current_period_end' => 'date',
'trial_ends_at' => 'date',
'scheduled_change_date' => 'date',
];
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function defaultPaymentMethod(): BelongsTo
{
return $this->belongsTo(PaymentMethod::class, 'default_payment_method_id');
}
public function invoices(): HasMany
{
return $this->hasMany(SubscriptionInvoice::class);
}
public function hasScheduledDowngrade(): bool
{
return $this->scheduled_plan_id !== null && $this->scheduled_change_date !== null;
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class SubscriptionInvoice extends Model
{
use SoftDeletes;
protected $fillable = [
'subscription_id',
'business_id',
'invoice_number',
'type',
'amount',
'status',
'invoice_date',
'due_date',
'paid_at',
'line_items',
'payment_method_id',
'stripe_invoice_id',
];
protected $casts = [
'amount' => 'decimal:2',
'invoice_date' => 'date',
'due_date' => 'date',
'paid_at' => 'datetime',
'line_items' => 'array',
];
public function subscription(): BelongsTo
{
return $this->belongsTo(Subscription::class);
}
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function paymentMethod(): BelongsTo
{
return $this->belongsTo(PaymentMethod::class);
}
public function isPaid(): bool
{
return $this->status === 'paid';
}
public function isPastDue(): bool
{
return $this->status === 'past_due' ||
($this->status === 'pending' && $this->due_date->isPast());
}
}

View File

@@ -21,6 +21,50 @@ class User extends Authenticatable implements FilamentUser
/** @use HasFactory<UserFactory> */
use HasFactory, HasRoles, Impersonate, Notifiable;
/**
* Boot the model and automatically generate short UUIDs
*/
protected static function boot()
{
parent::boot();
static::creating(function ($model) {
if (empty($model->uuid)) {
$model->uuid = self::generateShortUuid();
}
});
}
/**
* Generate a unique 5-character UUID without confusing characters
* Excludes: 0, O, 1, l (zero, capital O, one, lowercase L)
*/
protected static function generateShortUuid(): string
{
// Character set excluding 0, O, 1, l for clarity
$chars = '23456789ABCDEFGHJKMNPQRSTUVWXYZ';
$maxAttempts = 100;
$attempts = 0;
do {
// Generate 5-character random string
$uuid = '';
for ($i = 0; $i < 5; $i++) {
$uuid .= $chars[random_int(0, strlen($chars) - 1)];
}
// Check if unique in database
$exists = self::where('uuid', $uuid)->exists();
$attempts++;
if ($attempts >= $maxAttempts) {
throw new \RuntimeException('Unable to generate unique short UUID after '.$maxAttempts.' attempts');
}
} while ($exists);
return $uuid;
}
/**
* User type constants
*/
@@ -53,12 +97,14 @@ class User extends Authenticatable implements FilamentUser
* @var list<string>
*/
protected $fillable = [
'uuid',
'first_name',
'last_name',
'email',
'password',
'phone',
'position', // Job title/role
'company', // Company affiliation
'user_type', // admin, buyer, seller
'status', // active, inactive, suspended
'business_onboarding_completed',
@@ -70,6 +116,18 @@ class User extends Authenticatable implements FilamentUser
'preferred_contact_method', // email, phone, sms
'timezone',
'language_preference',
// Profile
'avatar_path',
'use_gravatar',
// Social Media
'linkedin_url',
'twitter_url',
'facebook_url',
'instagram_url',
'tiktok_url',
'github_url',
];
/**
@@ -96,6 +154,14 @@ class User extends Authenticatable implements FilamentUser
];
}
/**
* Get the route key for the model (use UUID instead of ID)
*/
public function getRouteKeyName(): string
{
return 'uuid';
}
/**
* Get the user's full name by combining first and last name
* This accessor ensures Filament can display the user's name
@@ -184,6 +250,16 @@ class User extends Authenticatable implements FilamentUser
return $this->hasMany(Contact::class);
}
/**
* Brands this user has access to (brand team membership)
*/
public function brands(): BelongsToMany
{
return $this->belongsToMany(Brand::class, 'brand_user')
->withPivot(['role', 'is_primary', 'permissions', 'role_template', 'permissions_updated_at'])
->withTimestamps();
}
// Helper methods for business associations
public function isPrimaryContactFor(Business $business): bool
{

View File

@@ -0,0 +1,275 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ViewAsSession extends Model
{
protected $fillable = [
'business_id',
'original_user_id',
'viewing_as_user_id',
'session_id',
'started_at',
'ended_at',
'duration_seconds',
'pages_viewed',
'pages_accessed',
'ip_address',
'user_agent',
];
protected $casts = [
'started_at' => 'datetime',
'ended_at' => 'datetime',
'pages_accessed' => 'array',
'pages_viewed' => 'integer',
'duration_seconds' => 'integer',
];
// No timestamps table - we track started_at and ended_at manually
public $timestamps = false;
/**
* Relationships
*/
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function originalUser(): BelongsTo
{
return $this->belongsTo(User::class, 'original_user_id');
}
public function viewingAsUser(): BelongsTo
{
return $this->belongsTo(User::class, 'viewing_as_user_id');
}
/**
* Scopes
*/
public function scopeForBusiness($query, int $businessId)
{
return $query->where('business_id', $businessId);
}
public function scopeActive($query)
{
return $query->whereNull('ended_at');
}
public function scopeEnded($query)
{
return $query->whereNotNull('ended_at');
}
public function scopeForOriginalUser($query, int $userId)
{
return $query->where('original_user_id', $userId);
}
public function scopeForViewingAsUser($query, int $userId)
{
return $query->where('viewing_as_user_id', $userId);
}
public function scopeRecent($query, int $days = 30)
{
return $query->where('started_at', '>=', now()->subDays($days));
}
public function scopeBySessionId($query, string $sessionId)
{
return $query->where('session_id', $sessionId);
}
/**
* Helper methods
*/
/**
* Check if session is currently active
*/
public function isActive(): bool
{
return is_null($this->ended_at);
}
/**
* Check if session has ended
*/
public function hasEnded(): bool
{
return ! is_null($this->ended_at);
}
/**
* Check if session is expired (based on config timeout)
*/
public function isExpired(): bool
{
if ($this->hasEnded()) {
return true;
}
$timeoutMinutes = config('permissions.view_as.timeout_minutes', 60);
$expiresAt = $this->started_at->addMinutes($timeoutMinutes);
return $expiresAt->isPast();
}
/**
* End the session
*/
public function end(): void
{
if ($this->hasEnded()) {
return;
}
$this->ended_at = now();
$this->duration_seconds = $this->started_at->diffInSeconds($this->ended_at);
$this->save();
}
/**
* Track a page visit
*/
public function trackPage(string $url): void
{
$pages = $this->pages_accessed ?? [];
$pages[] = [
'url' => $url,
'visited_at' => now()->toISOString(),
];
$this->pages_accessed = $pages;
$this->pages_viewed = count($pages);
$this->save();
}
/**
* Get formatted duration
*/
public function getFormattedDurationAttribute(): string
{
if ($this->isActive()) {
$duration = $this->started_at->diffInSeconds(now());
} else {
$duration = $this->duration_seconds ?? 0;
}
$hours = floor($duration / 3600);
$minutes = floor(($duration % 3600) / 60);
$seconds = $duration % 60;
if ($hours > 0) {
return sprintf('%dh %dm %ds', $hours, $minutes, $seconds);
} elseif ($minutes > 0) {
return sprintf('%dm %ds', $minutes, $seconds);
} else {
return sprintf('%ds', $seconds);
}
}
/**
* Get session status
*/
public function getStatusAttribute(): string
{
if ($this->hasEnded()) {
return 'ended';
}
if ($this->isExpired()) {
return 'expired';
}
return 'active';
}
/**
* Get status badge color for UI
*/
public function getStatusColorAttribute(): string
{
return match ($this->status) {
'active' => 'success',
'expired' => 'warning',
'ended' => 'ghost',
default => 'ghost',
};
}
/**
* Get remaining time before timeout
*/
public function getRemainingTimeAttribute(): ?string
{
if ($this->hasEnded()) {
return null;
}
$timeoutMinutes = config('permissions.view_as.timeout_minutes', 60);
$expiresAt = $this->started_at->addMinutes($timeoutMinutes);
if ($expiresAt->isPast()) {
return 'Expired';
}
return $expiresAt->diffForHumans();
}
/**
* Find active session by session ID
*/
public static function findActiveBySessionId(string $sessionId): ?self
{
return static::bySessionId($sessionId)->active()->first();
}
/**
* Find or create session
*/
public static function startSession(
int $businessId,
int $originalUserId,
int $viewingAsUserId,
string $sessionId
): self {
// End any existing active sessions for this original user
static::forOriginalUser($originalUserId)
->forBusiness($businessId)
->active()
->each(fn ($session) => $session->end());
// Create new session
return static::create([
'business_id' => $businessId,
'original_user_id' => $originalUserId,
'viewing_as_user_id' => $viewingAsUserId,
'session_id' => $sessionId,
'started_at' => now(),
'pages_viewed' => 0,
'pages_accessed' => [],
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
]);
}
/**
* Get summary for display
*/
public function getSummaryAttribute(): string
{
$originalName = $this->originalUser?->name ?? 'Unknown User';
$viewingAsName = $this->viewingAsUser?->name ?? 'Unknown User';
return "{$originalName} viewed as {$viewingAsName}";
}
}

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