Compare commits

...

44 Commits

Author SHA1 Message Date
Kelly
045fb3a9f3 Fix migration timestamp ordering
- Renamed migrations from 2024 to 2025 to fix execution order
- Prevents business_user table not found errors in tests
- Migrations must run after table creation, not before
2025-11-08 20:28:18 -07:00
Kelly
695efe6fcc Fix code style with Laravel Pint
Applied Laravel Pint formatting to fix 16 style issues including:
- ordered_imports
- method_chaining_indentation
- control_structure_braces
- new_with_parentheses
- not_operator_with_successor_space
- class_attributes_separation
- nullable_type_declaration_for_default_null_value

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 20:12:07 -07:00
Kelly
8a496cd0f0 Fix: Add pcntl to no-cache extension compilation path 2025-11-08 19:57:36 -07:00
Kelly
4872c5948f Add pcntl extension for Laravel Horizon support 2025-11-08 19:51:59 -07:00
Kelly
433015b8fd Trigger CI rebuild with fixed extension compilation 2025-11-08 19:37:11 -07:00
Kelly
8cecc5add5 Fix CI extension caching logic - ensure extensions compile when cache misses 2025-11-08 19:34:00 -07:00
Kelly
e1b5bacc76 Optimize CI build performance with PHP extension caching
Added caching for compiled PHP extensions to dramatically reduce CI build times:
- First build: Compiles extensions and caches them (same duration as before)
- Subsequent builds: Restores cached extensions, skipping 2-3 minute compilation
- Cache key: php-extensions-8.3 (static, only rebuilds on PHP version change)
- Extensions cached: intl, pdo, pdo_pgsql, zip, gd

This optimization ensures the web push notification feature tests run faster in CI.
2025-11-08 19:26:30 -07:00
Kelly
212ad19dbe Add Web Push Notifications for high-intent buyer signals
Implemented browser push notifications to alert business owners and admins
when high-intent buyer signals are detected in the analytics system.

Features:
- Installed laravel-notification-channels/webpush package (v10.2.0)
- Generated VAPID keys for web push authentication
- Created PushSubscription model and migration for managing subscriptions
- Created HighIntentSignalNotification with dynamic messages
- Created SendHighIntentSignalPushNotification queued listener
- Registered event listener in AppServiceProvider
- Fixed migration issues with batch_id columns (labs, order_items, carts)

Push notifications trigger when:
- Buyer views same product 3+ times (repeated_view)
- High engagement score 60%+ (high_engagement)
- Downloads product specs (spec_download)
- Clicks contact button (contact_click)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 18:47:37 -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
252 changed files with 33806 additions and 1603 deletions

View File

@@ -19,7 +19,9 @@
"Bash(grep:*)",
"Bash(sed:*)",
"Bash(php artisan:*)",
"Bash(php check_blade.php:*)"
"Bash(php check_blade.php:*)",
"Bash(git add:*)",
"Bash(git commit -m \"$(cat <<''EOF''\nfeat: implement correct batch tracking architecture\n\n- Replace incorrect batch schema with definitive architecture\n- Add batch_type enum (component vs homogenized)\n- Create batch_source_components pivot for traceability\n- Create product_batches pivot to link SKUs to batches\n- Update Batch model with correct relationships\n- Add QR code generation service\n- Include batch management controller and views\n- Document full system in BATCH_AND_LAB_SYSTEM.md\n\nThis implementation follows the definitive spec where:\n- Component batches: Tested input material (flower, rosin)\n- Homogenized batches: Mixed components with new testing\n- Batches link to SKU variants (NOT parent products)\n- QR codes show multiple COAs for homogenized products\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude <noreply@anthropic.com>\nEOF\n)\")"
],
"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

@@ -13,6 +13,19 @@ when:
# Install dependencies first (needed for php-lint to resolve traits/classes)
steps:
# Restore PHP extensions cache
restore-php-extensions-cache:
image: meltwater/drone-cache:dev
settings:
backend: "filesystem"
restore: true
cache_key: "php-extensions-8.3"
archive_format: "gzip"
mount:
- ".ci-cache/php-extensions"
volumes:
- /tmp/woodpecker-cache:/tmp/cache
# Restore Composer cache
restore-composer-cache:
image: meltwater/drone-cache:dev
@@ -33,9 +46,35 @@ steps:
- echo "Installing system dependencies..."
- apt-get update -qq
- apt-get install -y -qq git zip unzip libicu-dev libzip-dev libpng-dev libjpeg-dev libfreetype6-dev libpq-dev
- echo "Installing PHP extensions..."
- docker-php-ext-configure gd --with-freetype --with-jpeg
- docker-php-ext-install -j$(nproc) intl pdo pdo_pgsql zip gd
- echo "Checking for cached PHP extensions..."
- |
if [ -d ".ci-cache/php-extensions" ] && [ "$(ls -A .ci-cache/php-extensions 2>/dev/null)" ]; then
echo "✅ Restoring PHP extensions from cache..."
cp -r .ci-cache/php-extensions/* /usr/local/lib/php/extensions/no-debug-non-zts-20230831/ 2>/dev/null || true
cp -r .ci-cache/php-extensions/conf.d/* /usr/local/etc/php/conf.d/ 2>/dev/null || true
echo "Testing cached extensions..."
if php -m | grep -E "intl|pdo_pgsql|gd|zip|pcntl" > /dev/null 2>&1; then
echo "✅ Cached extensions loaded successfully!"
else
echo "⚠️ Cached extensions failed to load, will rebuild..."
echo "🔨 Compiling PHP extensions (this will take 2-3 minutes)..."
docker-php-ext-configure gd --with-freetype --with-jpeg
docker-php-ext-install -j$(nproc) intl pdo pdo_pgsql zip gd pcntl
echo "💾 Caching compiled extensions for future builds..."
mkdir -p .ci-cache/php-extensions/conf.d
cp -r /usr/local/lib/php/extensions/no-debug-non-zts-20230831/* .ci-cache/php-extensions/ 2>/dev/null || true
cp -r /usr/local/etc/php/conf.d/* .ci-cache/php-extensions/conf.d/ 2>/dev/null || true
fi
else
echo "📦 No cached extensions found"
echo "🔨 Compiling PHP extensions (this will take 2-3 minutes)..."
docker-php-ext-configure gd --with-freetype --with-jpeg
docker-php-ext-install -j$(nproc) intl pdo pdo_pgsql zip gd pcntl
echo "💾 Caching compiled extensions for future builds..."
mkdir -p .ci-cache/php-extensions/conf.d
cp -r /usr/local/lib/php/extensions/no-debug-non-zts-20230831/* .ci-cache/php-extensions/ 2>/dev/null || true
cp -r /usr/local/etc/php/conf.d/* .ci-cache/php-extensions/conf.d/ 2>/dev/null || true
fi
- echo "Installing Composer..."
- curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer --quiet
- echo "Creating minimal .env for package discovery..."
@@ -67,6 +106,19 @@ steps:
fi
- echo "Composer dependencies ready!"
# Rebuild PHP extensions cache
rebuild-php-extensions-cache:
image: meltwater/drone-cache:dev
settings:
backend: "filesystem"
rebuild: true
cache_key: "php-extensions-8.3"
archive_format: "gzip"
mount:
- ".ci-cache/php-extensions"
volumes:
- /tmp/woodpecker-cache:/tmp/cache
# Rebuild Composer cache
rebuild-composer-cache:
image: meltwater/drone-cache:dev

403
BATCH_AND_LAB_SYSTEM.md Normal file
View File

@@ -0,0 +1,403 @@
# Batch & Lab (COA) System - Definitive Guide
**Last Updated:** 2025-01-08
**Status:** Architecture Definition - DO NOT DEVIATE FROM THIS
---
## Core Terminology (NEVER USE OTHER TERMS)
| Term | Definition | Example |
|------|------------|---------|
| **Component** | Input material used to make products | Flower, rosin, trim, tubes, jars, labels |
| **Batch** | A tested bucket of finite material with a COA | 500 lbs of tested flower, 500 tested prerolls |
| **Lab / COA** | Certificate of Analysis - the test result | Lab report showing THC%, CBD%, contaminants |
| **Product** | Parent concept with variants | "Alien Market Preroll" |
| **SKU / Variant** | Sellable item (size/package variation) | "Alien Market Preroll - 1pk", "- 5pk" |
| **BOM** | Bill of Materials - recipe for a SKU | 3.5g flower + jar + label = 1/8oz SKU |
| **Homogenized Product** | Mixed components requiring NEW test | Flower + rosin preroll (must be tested) |
| **Conversion** | Refining raw material | Fresh frozen → rosin, flower → preroll |
---
## The Two Types of Batches
### Type 1: Component Batch (Unconverted or Converted Material)
**Definition:** A tested quantity of input material
**Examples:**
- 500 lbs cured flower (tested, has COA)
- 50 lbs rosin extracted from fresh frozen (tested, has COA)
- 100 lbs trim (tested, has COA)
**Rules:**
- Each batch has ONE Lab/COA
- Finite quantity tracked as inventory depletes
- When depleted, cannot reuse COA - must create NEW batch with NEW test
- Multiple SKUs can pull from same batch (all share same COA)
**Usage:**
```
Component Batch #123: 500 lbs "Alien Market" flower (tested 3/15/24)
├── SKU 1: Alien Market Flower - 1/8oz jar (sells 142 units = 17.75 lbs)
├── SKU 2: Alien Market Flower - 1/4oz jar (sells 200 units = 50 lbs)
├── SKU 3: Alien Market Flower - 1oz jar (sells 100 units = 100 lbs)
└── Remaining: 332.25 lbs available
All 3 SKUs show SAME COA (Component Batch #123's lab result)
```
### Type 2: Homogenized Product Batch (Mixed Components)
**Definition:** When you MIX multiple component batches, creating a NEW product that requires NEW testing
**Example:**
```
Component Batch #123: Flower (has COA #1)
Component Batch #456: Rosin (has COA #2)
Mix together to make enhanced prerolls
Homogenized Product Batch #789: 500 enhanced prerolls (requires NEW COA #3)
```
**Critical Rule:**
- Homogenized product MUST be tested (new Lab/COA required)
- QR code shows MULTIPLE COAs:
- **Primary:** Homogenized product COA (the preroll test)
- **Source:** Component COAs (flower COA, rosin COA) for traceability
**Exception:** Rosin does NOT need testing if used internally in your own products. It only needs testing if SOLD AS A COMPONENT to another buyer.
---
## Product vs SKU (Parent vs Variant)
### Current Schema Reality
The `products` table serves DUAL purposes:
**Parent Products:**
- `has_varieties = true`
- `parent_product_id = null`
- Example: "Alien Market Preroll"
**Variant Products (SKUs):**
- `has_varieties = false`
- `parent_product_id = <parent_id>`
- Example: "Alien Market Preroll - 1pk"
### How It Works
```
Product (Parent)
├── id: 100
├── name: "Alien Market Preroll"
├── has_varieties: true
└── description, images (shared)
Product (Variant 1 - SKU)
├── id: 101
├── name: "Alien Market Preroll - 1pk"
├── parent_product_id: 100
├── sku: "AM-PR-1PK"
├── wholesale_price: $5.00
└── bom_id: 50 (recipe)
Product (Variant 2 - SKU)
├── id: 102
├── name: "Alien Market Preroll - 5pk"
├── parent_product_id: 100
├── sku: "AM-PR-5PK"
├── wholesale_price: $22.00
└── bom_id: 51 (recipe)
```
---
## Bill of Materials (BOM)
**Rule:** EVERYTHING goes through a BOM to become a SKU, even "simple" products
### Simple Product BOM
```
SKU: "Alien Market Flower - 1/8oz"
BOM Components:
├── 3.5g flower (from Component Batch #123)
├── 1x jar
└── 1x label
```
### Complex Product BOM
```
SKU: "Alien Market Preroll - 1pk"
BOM Components:
├── 1x preroll (intermediate product)
│ └── Made from Component Batch #123 (flower)
├── 1x tube
└── 1x label
```
### Homogenized Product BOM
```
SKU: "Enhanced Preroll - 1pk"
BOM Components:
├── 1x enhanced preroll (Homogenized Product Batch #789)
│ ├── Flower (Component Batch #123 - COA #1)
│ └── Rosin (Component Batch #456 - COA #2)
│ └── NEW TEST REQUIRED (COA #3)
├── 1x tube
└── 1x label
QR Code shows: COA #3 (primary) + COA #1 + COA #2 (sources)
```
---
## Batch Linking Rules
### Rule 1: Unconverted Component → SKU
**When:** Selling flower, trim, or concentrate as-is (just packaging)
**Batch Behavior:** Reuse existing Component Batch ID
```
Component Batch #123: 500 lbs flower (tested)
SKU 1: 1/8oz jar → uses Batch #123 → shows COA #123
SKU 2: 1/4oz jar → uses Batch #123 → shows COA #123
SKU 3: 1oz jar → uses Batch #123 → shows COA #123
All SKUs share the SAME batch and SAME COA
```
### Rule 2: Homogenized Product → SKU
**When:** Mixing multiple components (conversion happened)
**Batch Behavior:** Create NEW Homogenized Product Batch ID with NEW COA
```
Component Batch #123: Flower (COA #1)
Component Batch #456: Rosin (COA #2)
Manufacturing: Mix flower + rosin
Send sample to lab → get NEW COA #3
Create Homogenized Product Batch #789 (COA #3)
SKU 1: Enhanced Preroll 1pk → uses Batch #789 → shows COA #3, #1, #2
SKU 2: Enhanced Preroll 5pk → uses Batch #789 → shows COA #3, #1, #2
All SKUs share the SAME homogenized batch but show MULTIPLE COAs
```
---
## Correct Database Schema
### batches
```sql
CREATE TABLE batches (
id BIGINT PRIMARY KEY,
business_id BIGINT NOT NULL, -- Multi-tenancy
-- What type of batch?
batch_type ENUM('component', 'homogenized') NOT NULL,
-- If component batch
component_id BIGINT NULL, -- Links to components table
-- If homogenized batch (links to source component batches)
-- Uses batch_source_components pivot table
-- Identification
batch_number VARCHAR(100) UNIQUE NOT NULL,
-- Inventory
quantity_total INTEGER NOT NULL,
quantity_remaining INTEGER NOT NULL,
quantity_unit VARCHAR(20), -- lbs, g, units
-- Primary COA for this batch
primary_coa_id BIGINT, -- Links to labs table
-- Dates
production_date DATE,
test_date DATE,
expiration_date DATE,
-- Status
is_active BOOLEAN DEFAULT true,
is_tested BOOLEAN DEFAULT false,
is_quarantined BOOLEAN DEFAULT false,
timestamps,
soft_deletes
);
```
### batch_source_components (for homogenized products)
```sql
CREATE TABLE batch_source_components (
id BIGINT PRIMARY KEY,
homogenized_batch_id BIGINT NOT NULL, -- The mixed product batch
source_batch_id BIGINT NOT NULL, -- Component batch used
quantity_used DECIMAL(10,2),
unit VARCHAR(20),
timestamps
);
```
### product_batches (pivot - which SKUs use which batches)
```sql
CREATE TABLE product_batches (
id BIGINT PRIMARY KEY,
product_id BIGINT NOT NULL, -- The SKU (variant product)
batch_id BIGINT NOT NULL, -- The batch being used
quantity_allocated INTEGER DEFAULT 0,
is_active BOOLEAN DEFAULT true,
timestamps
);
```
### labs (COAs)
```sql
-- Existing labs table
-- Add batch_id column to link COAs to specific batches
ALTER TABLE labs ADD COLUMN batch_id BIGINT NULL;
```
---
## QR Code Display Logic
### Simple Product (Component Batch)
```
Scan QR → Load Batch #123 → Show:
- Product Name
- Batch Number
- Test Date
- COA #123 (PDF/data)
```
### Homogenized Product
```
Scan QR → Load Batch #789 → Show:
- Product Name
- Batch Number
- Test Date
- Primary COA #3 (the homogenized product test) ← FEATURED
- Source COAs:
- COA #1 (flower source)
- COA #2 (rosin source)
```
---
## Manufacturing Workflow Examples
### Example 1: Selling Flower (No Conversion)
```
1. Receive 500 lbs flower → Create Component Batch #123
2. Send sample to lab → Receive COA #1 → Link to Batch #123
3. Create SKUs:
- Product: "Alien Market Flower" (parent)
- SKU 1: "Alien Market Flower - 1/8oz" (variant, product_id=101)
- SKU 2: "Alien Market Flower - 1/4oz" (variant, product_id=102)
4. Link SKUs to Batch:
- product_batches: product_id=101, batch_id=123
- product_batches: product_id=102, batch_id=123
5. Sell inventory:
- SKU 1 sells 100 units (12.5 lbs) → Batch #123: 487.5 lbs remaining
- SKU 2 sells 50 units (12.5 lbs) → Batch #123: 475 lbs remaining
6. When Batch #123 depletes → Create NEW Batch #124 with NEW COA
```
### Example 2: Homogenized Prerolls
```
1. Have existing:
- Component Batch #123: Flower (COA #1)
- Component Batch #456: Rosin (COA #2)
2. Manufacturing:
- Mix 10 lbs flower + 2 lbs rosin
- Create 500 enhanced prerolls
3. Testing:
- Send sample to lab → Receive COA #3
4. Create Homogenized Batch:
- Create Batch #789 (type='homogenized', primary_coa_id=3)
- Link sources:
- batch_source_components: homogenized=789, source=123, qty=10 lbs
- batch_source_components: homogenized=789, source=456, qty=2 lbs
5. Create SKUs:
- Product: "Enhanced Preroll" (parent)
- SKU 1: "Enhanced Preroll - 1pk" (variant, product_id=201)
- SKU 2: "Enhanced Preroll - 5pk" (variant, product_id=202)
6. Link SKUs to Homogenized Batch:
- product_batches: product_id=201, batch_id=789
- product_batches: product_id=202, batch_id=789
7. QR Code Scan:
- Shows COA #3 (primary)
- Shows COA #1 + COA #2 (sources, via batch_source_components)
```
---
## Common Mistakes to Avoid
**WRONG:** Linking batches to parent products
**RIGHT:** Link batches to SKU variants only
**WRONG:** Reusing COAs for new batches
**RIGHT:** New batch = new test = new COA
**WRONG:** Testing rosin used internally
**RIGHT:** Only test rosin if selling it as a component to buyers
**WRONG:** Not testing homogenized products
**RIGHT:** Mixed components ALWAYS require new test
**WRONG:** Showing only homogenized COA on QR code
**RIGHT:** Show homogenized COA + all source component COAs
---
## Decision Summary for Worktree
**The current worktree's batch schema is FUNDAMENTALLY WRONG because:**
1. ❌ Links `batches.product_id` to products (could be parent OR variant)
2. ❌ Doesn't distinguish component batches vs homogenized batches
3. ❌ Doesn't track source components for homogenized products
4. ❌ No way to show multiple COAs on QR code for homogenized products
**Recommended Action:**
1. Reject the current worktree architecture
2. Design from scratch using THIS document as the spec
3. Create new migrations that implement the schema above
4. Build UI that handles both component and homogenized batch workflows
---
**This document is the single source of truth. Do not deviate without updating this file first.**

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.

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! 🚀

View File

@@ -0,0 +1,132 @@
<?php
// Connect to live MySQL database
$host = 'sql1.creationshop.net';
$username = 'claude';
$password = 'claude';
$database = 'hub_cannabrands';
$conn = new PDO("mysql:host=$host;dbname=$database", $username, $password);
$conn->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
echo "=== SAMPLE BOM DATA (with components) ===\n";
$stmt = $conn->query('
SELECT b.*, p.name as product_name
FROM bom b
LEFT JOIN products p ON b.product_id = p.id
LIMIT 5
');
$boms = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($boms as $bom) {
echo "\nBOM ID: {$bom['id']}\n";
echo " Product: {$bom['product_name']} (ID: {$bom['product_id']})\n";
echo ' Description: '.($bom['description'] ?? 'N/A')."\n";
echo " Total Cost: \${$bom['total_cost']}\n";
echo " Total Quantity: {$bom['total_quantity']}\n";
echo " Status: {$bom['status']}\n";
// Get components for this BOM
$compStmt = $conn->prepare('
SELECT bc.*
FROM bom_components bc
WHERE bc.bom_id = ?
');
$compStmt->execute([$bom['id']]);
$bomComponents = $compStmt->fetchAll(PDO::FETCH_ASSOC);
if (! empty($bomComponents)) {
echo " Components ({count}):\n";
foreach ($bomComponents as $bc) {
// Get component details separately
$compDetailStmt = $conn->prepare('SELECT * FROM components WHERE id = ?');
$compDetailStmt->execute([$bc['component_id']]);
$compDetail = $compDetailStmt->fetch(PDO::FETCH_ASSOC);
if ($compDetail) {
echo " - Component ID: {$bc['component_id']}\n";
echo " Name: {$compDetail['name']}\n";
echo " SKU: {$compDetail['sku']}\n";
echo " Type: {$compDetail['type']}\n";
echo " Quantity: {$bc['quantity']}\n";
echo " Subtotal Cost: \${$bc['subtotal_cost']}\n";
}
}
}
}
echo "\n\n=== SAMPLE BOM COMPONENT TEMPLATES ===\n";
$stmt = $conn->query('SELECT * FROM bom_component_templates LIMIT 10');
$templates = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($templates as $template) {
$compStmt = $conn->prepare('SELECT name, sku, type FROM components WHERE id = ?');
$compStmt->execute([$template['component_id']]);
$comp = $compStmt->fetch(PDO::FETCH_ASSOC);
$prodStmt = $conn->prepare('SELECT name FROM products WHERE id = ?');
$prodStmt->execute([$template['product_id']]);
$prod = $prodStmt->fetch(PDO::FETCH_ASSOC);
echo "\nTemplate ID: {$template['id']}\n";
echo " Component: {$comp['name']} ({$comp['sku']})\n";
echo " Product: {$prod['name']}\n";
echo ' Quantity: '.($template['quantity'] ?? 'N/A')."\n";
}
echo "\n\n=== SAMPLE PRODUCTION REPORTS ===\n";
$stmt = $conn->query('SELECT * FROM production_reports LIMIT 3');
$reports = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($reports as $report) {
$prodStmt = $conn->prepare('SELECT name FROM products WHERE id = ?');
$prodStmt->execute([$report['product_id']]);
$prod = $prodStmt->fetch(PDO::FETCH_ASSOC);
echo "\nReport ID: {$report['id']}\n";
echo " Product: {$prod['name']}\n";
echo " Wash Date: {$report['wash_date']}\n";
echo " Cultivator: {$report['cultivator']}\n";
echo ' Varietal: '.($report['varietal'] ?? 'N/A')."\n";
echo " Type: {$report['type']}\n";
echo " Starting Weight: {$report['fresh_frozen_starting_weight']} lbs\n";
echo " Soak Time: {$report['soak_time']} mins\n";
echo " Room Temp: {$report['room_temperature']}°F\n";
echo " Vessel Temp: {$report['vessel_temperature']}°F\n";
echo " Status: {$report['status']}\n";
if ($report['notes']) {
echo " Notes: {$report['notes']}\n";
}
}
echo "\n\n=== PRE-ROLL REPORTS (if exists) ===\n";
$stmt = $conn->query('DESCRIBE pre_roll_reports');
$columns = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo "Table structure:\n";
foreach ($columns as $col) {
echo " {$col['Field']} - {$col['Type']}\n";
}
$stmt = $conn->query('SELECT * FROM pre_roll_reports LIMIT 3');
$preRolls = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($preRolls as $pr) {
echo "\nPre-Roll Report ID: {$pr['id']}\n";
foreach ($pr as $key => $value) {
if ($key !== 'id' && $value !== null) {
echo " $key: $value\n";
}
}
}
echo "\n\n=== COUNTS ===\n";
$stmt = $conn->query('SELECT COUNT(*) as total FROM bom');
echo 'Total BOMs: '.$stmt->fetch(PDO::FETCH_ASSOC)['total']."\n";
$stmt = $conn->query('SELECT COUNT(*) as total FROM bom_components');
echo 'Total BOM Components: '.$stmt->fetch(PDO::FETCH_ASSOC)['total']."\n";
$stmt = $conn->query('SELECT COUNT(*) as total FROM bom_component_templates');
echo 'Total BOM Component Templates: '.$stmt->fetch(PDO::FETCH_ASSOC)['total']."\n";
$stmt = $conn->query('SELECT COUNT(*) as total FROM production_reports');
echo 'Total Production Reports: '.$stmt->fetch(PDO::FETCH_ASSOC)['total']."\n";
$stmt = $conn->query('SELECT COUNT(*) as total FROM pre_roll_reports');
echo 'Total Pre-Roll Reports: '.$stmt->fetch(PDO::FETCH_ASSOC)['total']."\n";

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

@@ -0,0 +1,81 @@
<?php
namespace App\Events\Analytics;
use App\Models\Analytics\BuyerEngagementScore;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class BuyerEngagementUpdated implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public int $businessId,
public int $buyerBusinessId,
public BuyerEngagementScore $engagementScore,
public ?int $previousScore = null
) {}
/**
* Get the channels the event should broadcast on.
*/
public function broadcastOn(): Channel
{
// Broadcast to the seller's business analytics channel
return new Channel("business.{$this->businessId}.analytics");
}
/**
* Get the data to broadcast.
*/
public function broadcastWith(): array
{
return [
'buyer_business_id' => $this->buyerBusinessId,
'buyer_business_name' => $this->engagementScore->buyerBusiness?->name,
'score' => $this->engagementScore->score,
'previous_score' => $this->previousScore,
'score_change' => $this->previousScore
? $this->engagementScore->score - $this->previousScore
: null,
'score_tier' => $this->engagementScore->score_tier,
'recency_score' => $this->engagementScore->recency_score,
'frequency_score' => $this->engagementScore->frequency_score,
'depth_score' => $this->engagementScore->depth_score,
'intent_score' => $this->engagementScore->intent_score,
'days_since_last_interaction' => $this->engagementScore->days_since_last_interaction,
'sessions_30d' => $this->engagementScore->sessions_30d,
'product_views_30d' => $this->engagementScore->product_views_30d,
'last_interaction_at' => $this->engagementScore->last_interaction_at?->toIso8601String(),
'calculated_at' => $this->engagementScore->calculated_at->toIso8601String(),
];
}
/**
* The event's broadcast name.
*/
public function broadcastAs(): string
{
return 'buyer-engagement-updated';
}
/**
* Determine if this event should broadcast
* Only broadcast significant changes (tier change or score change >= 10 points)
*/
public function shouldBroadcast(): bool
{
if (! $this->previousScore) {
return true; // Always broadcast new scores
}
$scoreChange = abs($this->engagementScore->score - $this->previousScore);
// Broadcast if score changed by 10+ points
return $scoreChange >= 10;
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Events\Analytics;
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 HighIntentSignalDetected implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public int $businessId,
public int $buyerBusinessId,
public IntentSignal $signal,
public ?BuyerEngagementScore $engagementScore = null
) {}
/**
* Get the channels the event should broadcast on.
*/
public function broadcastOn(): Channel
{
// Broadcast to the seller's business analytics channel
return new Channel("business.{$this->businessId}.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,
'context_type' => $this->signal->context_type,
'context_id' => $this->signal->context_id,
'total_engagement_score' => $this->engagementScore?->score,
'engagement_tier' => $this->engagementScore?->score_tier,
'detected_at' => $this->signal->detected_at->toIso8601String(),
'metadata' => $this->signal->metadata,
];
}
/**
* The event's broadcast name.
*/
public function broadcastAs(): string
{
return 'high-intent-signal-detected';
}
}

View File

@@ -0,0 +1,72 @@
<?php
namespace App\Events\Analytics;
use App\Models\Analytics\AnalyticsEvent;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class NewAnalyticsEvent implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public int $businessId,
public AnalyticsEvent $event
) {}
/**
* Get the channels the event should broadcast on.
*/
public function broadcastOn(): Channel
{
// Broadcast to the seller's business analytics channel
return new Channel("business.{$this->businessId}.analytics");
}
/**
* Get the data to broadcast.
*/
public function broadcastWith(): array
{
return [
'event_id' => $this->event->id,
'event_type' => $this->event->event_type,
'user_id' => $this->event->user_id,
'trackable_type' => $this->event->trackable_type,
'trackable_id' => $this->event->trackable_id,
'properties' => $this->event->properties,
'session_id' => $this->event->session_id,
'created_at' => $this->event->created_at->toIso8601String(),
];
}
/**
* The event's broadcast name.
*/
public function broadcastAs(): string
{
return 'new-analytics-event';
}
/**
* Determine if this event should broadcast
* Only broadcast important event types
*/
public function shouldBroadcast(): bool
{
$importantEvents = [
'product_view',
'add_to_cart',
'order_placed',
'contact_clicked',
'spec_downloaded',
'video_watched',
];
return in_array($this->event->event_type, $importantEvents);
}
}

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

@@ -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,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,186 @@
<?php
namespace App\Http\Controllers\Analytics;
use App\Http\Controllers\Controller;
use App\Services\AnalyticsTracker;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class ClickTrackingController extends Controller
{
/**
* Track a click event
*/
public function track(Request $request)
{
try {
$validated = $request->validate([
'business_id' => 'required|integer|exists:businesses,id',
'element_type' => 'required|string|max:50',
'element_id' => 'nullable|string|max:255',
'element_text' => 'nullable|string|max:255',
'destination_url' => 'nullable|url|max:500',
'metadata' => 'nullable|array',
]);
// Verify user has access to this business (if authenticated)
if (auth()->check()) {
$user = auth()->user();
$hasAccess = $user->businesses()
->where('businesses.id', $validated['business_id'])
->exists();
if (! $hasAccess && $user->user_type !== 'admin') {
return response()->json(['error' => 'Unauthorized'], 403);
}
}
// Create click tracking record
$tracker = new AnalyticsTracker;
$click = $tracker->trackClick(
$validated['business_id'],
$validated['element_type'],
$validated['element_id'] ?? null,
$validated['element_text'] ?? null,
$validated['destination_url'] ?? null,
$validated['metadata'] ?? []
);
return response()->json([
'success' => true,
'click_id' => $click->id,
]);
} catch (\Illuminate\Validation\ValidationException $e) {
return response()->json([
'error' => 'Validation failed',
'messages' => $e->errors(),
], 422);
} catch (\Exception $e) {
Log::error('Click tracking error', [
'error' => $e->getMessage(),
'request' => $request->all(),
]);
return response()->json([
'error' => 'Tracking failed',
], 500);
}
}
/**
* Track product card click
*/
public function trackProductClick(Request $request)
{
try {
$validated = $request->validate([
'business_id' => 'required|integer|exists:businesses,id',
'product_id' => 'required|integer|exists:products,id',
'source' => 'nullable|string|max:100',
]);
$tracker = new AnalyticsTracker;
$click = $tracker->trackClick(
$validated['business_id'],
'product_card',
$validated['product_id'],
null,
null,
[
'source' => $validated['source'] ?? 'unknown',
'timestamp' => now()->toISOString(),
]
);
return response()->json([
'success' => true,
'click_id' => $click->id,
]);
} catch (\Exception $e) {
Log::error('Product click tracking error', [
'error' => $e->getMessage(),
'request' => $request->all(),
]);
return response()->json(['error' => 'Tracking failed'], 500);
}
}
/**
* Track CTA button click
*/
public function trackCtaClick(Request $request)
{
try {
$validated = $request->validate([
'business_id' => 'required|integer|exists:businesses,id',
'cta_type' => 'required|string|max:100',
'page_url' => 'nullable|url|max:500',
]);
$tracker = new AnalyticsTracker;
$click = $tracker->trackClick(
$validated['business_id'],
'cta_button',
$validated['cta_type'],
null,
null,
[
'page_url' => $validated['page_url'] ?? $request->header('Referer'),
'timestamp' => now()->toISOString(),
]
);
return response()->json([
'success' => true,
'click_id' => $click->id,
]);
} catch (\Exception $e) {
Log::error('CTA click tracking error', [
'error' => $e->getMessage(),
'request' => $request->all(),
]);
return response()->json(['error' => 'Tracking failed'], 500);
}
}
/**
* Track navigation link click
*/
public function trackNavigationClick(Request $request)
{
try {
$validated = $request->validate([
'business_id' => 'required|integer|exists:businesses,id',
'nav_item' => 'required|string|max:100',
'destination_url' => 'nullable|url|max:500',
]);
$tracker = new AnalyticsTracker;
$click = $tracker->trackClick(
$validated['business_id'],
'navigation',
$validated['nav_item'],
$validated['nav_item'],
$validated['destination_url'] ?? null,
[
'timestamp' => now()->toISOString(),
]
);
return response()->json([
'success' => true,
'click_id' => $click->id,
]);
} catch (\Exception $e) {
Log::error('Navigation click tracking error', [
'error' => $e->getMessage(),
'request' => $request->all(),
]);
return response()->json(['error' => 'Tracking failed'], 500);
}
}
}

View File

@@ -0,0 +1,193 @@
<?php
namespace App\Http\Controllers\Analytics;
use App\Http\Controllers\Controller;
use App\Jobs\Analytics\UpdateCampaignStatsJob;
use App\Models\Analytics\EmailInteraction;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class EmailTrackingController extends Controller
{
/**
* Track email open via tracking pixel
*/
public function trackOpen(Request $request, string $token)
{
try {
$interaction = EmailInteraction::where('tracking_token', $token)->first();
if (! $interaction) {
return $this->transparentPixel();
}
// Ensure business_id scoping
if (! $interaction->business_id) {
Log::warning('EmailInteraction missing business_id', ['interaction_id' => $interaction->id]);
return $this->transparentPixel();
}
// Extract device and email client info
$userAgent = $request->userAgent();
$emailClient = $this->detectEmailClient($userAgent);
$deviceType = $this->detectDeviceType($userAgent);
// Record the open
$interaction->recordOpen($emailClient, $deviceType);
// Queue campaign stats update
if ($interaction->campaign_id) {
UpdateCampaignStatsJob::dispatch(
$interaction->campaign_id,
$interaction->business_id
);
}
return $this->transparentPixel();
} catch (\Exception $e) {
Log::error('Email tracking error', [
'token' => $token,
'error' => $e->getMessage(),
]);
return $this->transparentPixel();
}
}
/**
* Track email link click
*/
public function trackClick(Request $request, string $token)
{
try {
$interaction = EmailInteraction::where('tracking_token', $token)->first();
if (! $interaction) {
return redirect('/');
}
// Ensure business_id scoping
if (! $interaction->business_id) {
Log::warning('EmailInteraction missing business_id', ['interaction_id' => $interaction->id]);
return redirect('/');
}
$url = $request->input('url');
$linkId = $request->input('link_id');
if (! $url) {
return redirect('/');
}
// Record the click
$interaction->recordClick($url, $linkId);
// Queue campaign stats update
if ($interaction->campaign_id) {
UpdateCampaignStatsJob::dispatch(
$interaction->campaign_id,
$interaction->business_id
);
}
return redirect($url);
} catch (\Exception $e) {
Log::error('Email click tracking error', [
'token' => $token,
'error' => $e->getMessage(),
]);
return redirect('/');
}
}
/**
* Track age verification from email
*/
public function trackAgeVerification(Request $request, string $token)
{
try {
$interaction = EmailInteraction::where('tracking_token', $token)->first();
if (! $interaction) {
return response()->json(['error' => 'Invalid token'], 404);
}
// Ensure business_id scoping
if (! $interaction->business_id) {
Log::warning('EmailInteraction missing business_id', ['interaction_id' => $interaction->id]);
return response()->json(['error' => 'Invalid token'], 404);
}
$verified = $request->input('verified', false);
// Update metadata with age verification status
$metadata = $interaction->metadata ?? [];
$metadata['age_verified'] = $verified;
$metadata['age_verified_at'] = now()->toISOString();
$interaction->update(['metadata' => $metadata]);
return response()->json(['success' => true]);
} catch (\Exception $e) {
Log::error('Age verification tracking error', [
'token' => $token,
'error' => $e->getMessage(),
]);
return response()->json(['error' => 'Tracking failed'], 500);
}
}
/**
* Return transparent 1x1 pixel GIF
*/
protected function transparentPixel()
{
return response(base64_decode('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'))
->header('Content-Type', 'image/gif')
->header('Cache-Control', 'no-cache, no-store, must-revalidate')
->header('Pragma', 'no-cache')
->header('Expires', '0');
}
/**
* Detect email client from user agent
*/
protected function detectEmailClient(string $userAgent): string
{
if (str_contains($userAgent, 'Apple Mail')) {
return 'Apple Mail';
}
if (str_contains($userAgent, 'Outlook')) {
return 'Outlook';
}
if (str_contains($userAgent, 'Gmail')) {
return 'Gmail';
}
if (str_contains($userAgent, 'Yahoo')) {
return 'Yahoo Mail';
}
if (str_contains($userAgent, 'Thunderbird')) {
return 'Thunderbird';
}
return 'Unknown';
}
/**
* Detect device type from user agent
*/
protected function detectDeviceType(string $userAgent): string
{
if (preg_match('/mobile|android|iphone|ipad|tablet/i', $userAgent)) {
return 'mobile';
}
return 'desktop';
}
}

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

@@ -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.
*/
@@ -23,9 +29,10 @@ class UserController extends Controller
->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)
$users = $business->users()
->withPivot('contact_type', 'is_primary', 'permissions')
->withPivot('contact_type', 'is_primary', 'permissions', 'role', 'role_template')
->with('roles')
->orderBy('is_primary', 'desc')
->orderBy('first_name')
->get();
@@ -33,6 +40,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,400 @@
<?php
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Models\Batch;
use App\Models\Business;
use App\Models\Component;
use App\Models\Product;
use App\Services\QrCodeService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
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 components owned by this business (for component batches)
$components = Component::where('business_id', $business->id)
->orderBy('name', 'asc')
->get();
// Get existing component batches (for homogenized batch source selection)
$componentBatches = Batch::where('business_id', $business->id)
->where('batch_type', 'component')
->where('is_active', true)
->where('quantity_remaining', '>', 0)
->with('component')
->orderBy('batch_number', 'desc')
->get();
return view('seller.batches.create', compact('business', 'components', 'componentBatches'));
}
/**
* Store a newly created batch - DEFINITIVE VERSION
*
* See BATCH_AND_LAB_SYSTEM.md for architecture details
*/
public function store(Request $request, Business $business)
{
$validated = $request->validate([
// Batch type (component or homogenized)
'batch_type' => 'required|in:component,homogenized',
// Component batch specific
'component_id' => 'required_if:batch_type,component|nullable|exists:components,id',
// Basic batch info
'batch_number' => 'required|string|max:100|unique:batches,batch_number',
'internal_code' => 'nullable|string|max:100',
'production_date' => 'required|date',
'expiration_date' => 'nullable|date|after:production_date',
// Inventory tracking
'quantity_total' => 'required|numeric|min:0',
'quantity_remaining' => 'required|numeric|min:0',
'quantity_unit' => 'required|in:lbs,g,kg,oz,units',
// Status flags
'is_active' => 'sometimes|boolean',
'is_tested' => 'sometimes|boolean',
'is_quarantined' => 'sometimes|boolean',
// Notes
'notes' => 'nullable|string',
// Source components (for homogenized batches)
'source_components' => 'required_if:batch_type,homogenized|nullable|array',
'source_components.*.batch_id' => 'required_with:source_components|exists:batches,id',
'source_components.*.quantity_used' => 'required_with:source_components|numeric|min:0',
'source_components.*.unit' => 'required_with:source_components|in:lbs,g,kg,oz,units',
]);
// Verify component belongs to this business (if component batch)
if ($validated['batch_type'] === 'component' && $validated['component_id']) {
Component::where('business_id', $business->id)
->where('id', $validated['component_id'])
->firstOrFail();
}
// Verify source batches belong to this business (if homogenized)
if ($validated['batch_type'] === 'homogenized' && ! empty($validated['source_components'])) {
$sourceBatchIds = collect($validated['source_components'])->pluck('batch_id');
$validCount = Batch::where('business_id', $business->id)
->whereIn('id', $sourceBatchIds)
->count();
if ($validCount !== $sourceBatchIds->count()) {
return back()->withErrors(['source_components' => 'Some source batches do not belong to this business']);
}
}
DB::beginTransaction();
try {
// Set business_id and handle checkboxes/hidden fields
$validated['business_id'] = $business->id;
$validated['is_active'] = $request->has('is_active');
// is_tested and is_quarantined come from hidden fields (always tested with COA, never quarantined initially)
$validated['is_tested'] = (bool) $request->input('is_tested', true);
$validated['is_quarantined'] = (bool) $request->input('is_quarantined', false);
// Null out component_id for homogenized batches
if ($validated['batch_type'] === 'homogenized') {
$validated['component_id'] = null;
}
// Create batch
$batch = Batch::create($validated);
// Handle source components for homogenized batches
if ($validated['batch_type'] === 'homogenized' && ! empty($validated['source_components'])) {
foreach ($validated['source_components'] as $sourceComponent) {
// Attach source batch with quantity used
$batch->sourceComponents()->attach($sourceComponent['batch_id'], [
'quantity_used' => $sourceComponent['quantity_used'],
'unit' => $sourceComponent['unit'],
]);
// Optionally: Reduce quantity_remaining on source batch
// $sourceBatch = Batch::find($sourceComponent['batch_id']);
// $sourceBatch->consume($sourceComponent['quantity_used']);
}
}
DB::commit();
return redirect()
->route('seller.business.batches.index', $business->slug)
->with('success', "Batch {$batch->batch_number} created successfully. Next: Link to SKU variants and upload COA.");
} catch (\Exception $e) {
DB::rollBack();
return back()
->withInput()
->withErrors(['error' => 'Failed to create batch: '.$e->getMessage()]);
}
}
/**
* 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', 'product.brand']);
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,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

@@ -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

@@ -30,13 +30,51 @@ class SettingsController extends Controller
'license_number' => 'nullable|string|max:255',
'license_type' => 'nullable|string',
'physical_address' => 'nullable|string|max:255',
'physical_suite' => 'nullable|string|max:50',
'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',
'logo' => 'nullable|image|max:2048', // 2MB max
'banner' => 'nullable|image|max:4096', // 4MB max
'remove_logo' => 'nullable|boolean',
'remove_banner' => 'nullable|boolean',
]);
// Handle logo removal
if ($request->has('remove_logo') && $business->logo_path) {
\Storage::disk('public')->delete($business->logo_path);
$validated['logo_path'] = null;
}
// Handle logo upload
if ($request->hasFile('logo')) {
// Delete old logo if exists
if ($business->logo_path) {
\Storage::disk('public')->delete($business->logo_path);
}
$validated['logo_path'] = $request->file('logo')->store('businesses/logos', 'public');
}
// Handle banner removal
if ($request->has('remove_banner') && $business->banner_path) {
\Storage::disk('public')->delete($business->banner_path);
$validated['banner_path'] = null;
}
// Handle banner upload
if ($request->hasFile('banner')) {
// Delete old banner if exists
if ($business->banner_path) {
\Storage::disk('public')->delete($business->banner_path);
}
$validated['banner_path'] = $request->file('banner')->store('businesses/banners', 'public');
}
// Remove file inputs from validated data (already handled above)
unset($validated['logo'], $validated['banner'], $validated['remove_logo'], $validated['remove_banner']);
$business->update($validated);
return redirect()
@@ -47,9 +85,152 @@ class SettingsController extends Controller
/**
* 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'));
$query = $business->users();
// 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'));
}
/**
* Store a newly created user invitation.
*/
public function inviteUser(Business $business, Request $request)
{
$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!');
}
/**
* Update user information and permissions.
*/
public function updateUser(Business $business, \App\Models\User $user, Request $request)
{
// 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');
}
$validated = $request->validate([
'first_name' => 'required|string|max:255',
'last_name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email,'.$user->id,
'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',
'permissions' => 'nullable|array',
]);
// Combine first and last name
$fullName = trim($validated['first_name'].' '.$validated['last_name']);
// Update user
$user->update([
'name' => $fullName,
'email' => $validated['email'],
'phone' => $validated['phone'] ?? null,
]);
// Update role
$user->syncRoles([$validated['role']]);
// Update business_user pivot data
$business->users()->updateExistingPivot($user->id, [
'role' => $validated['role'],
'contact_type' => $request->has('is_point_of_contact') ? 'primary' : null,
'permissions' => $validated['permissions'] ?? null,
]);
return redirect()
->route('seller.business.settings.users', $business->slug)
->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!');
}
/**
@@ -60,12 +241,52 @@ class SettingsController extends Controller
return view('seller.settings.orders', compact('business'));
}
/**
* Update the order settings.
*/
public function updateOrders(Business $business, Request $request)
{
$validated = $request->validate([
'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',
]);
// 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.orders', $business->slug)
->with('success', 'Order settings updated successfully!');
}
/**
* Display the brands management page.
*/
public function brands(Business $business)
{
return view('seller.settings.brands', compact('business'));
$brands = $business->brands()
->orderBy('sort_order')
->orderBy('name')
->get();
return view('seller.settings.brands', compact('business', 'brands'));
}
/**
@@ -84,6 +305,26 @@ class SettingsController extends Controller
return view('seller.settings.invoices', compact('business'));
}
/**
* Update the invoice settings.
*/
public function updateInvoices(Business $business, Request $request)
{
$validated = $request->validate([
'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',
]);
$business->update($validated);
return redirect()
->route('seller.business.settings.invoices', $business->slug)
->with('success', 'Invoice settings updated successfully!');
}
/**
* Display the manage licenses page.
*/
@@ -108,6 +349,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.
*/

View File

@@ -0,0 +1,223 @@
<?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' => 'required|date',
'input_batch_id' => 'required|exists:batches,id',
'starting_weight' => 'required|numeric|min:0',
'soak_time_minutes' => 'required|integer|min:0',
'room_temperature_f' => 'required|numeric',
'vessel_temperature_f' => 'required|numeric',
'strain' => 'required|string|max:255',
'wash_cycles' => 'required|array|min:1',
'wash_cycles.*.cycle' => 'required|integer|min:1',
'wash_cycles.*.forward_speed' => 'required|integer|min:1|max:10',
'wash_cycles.*.reverse_speed' => 'required|integer|min:1|max:10',
'wash_cycles.*.pause' => 'required|integer|min:0',
'wash_cycles.*.run_time' => 'required|integer|min:1',
'notes' => 'nullable|string',
]);
// Verify batch belongs to business
$inputBatch = Batch::where('business_id', $business->id)
->where('id', $validated['input_batch_id'])
->firstOrFail();
// Verify sufficient quantity available
if ($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
$conversion = Conversion::create([
'business_id' => $business->id,
'conversion_type' => 'hash_wash',
'status' => 'in_progress',
'internal_name' => $validated['strain'].' Hash Wash #'.now()->format('Ymd-His'),
'started_at' => $validated['wash_date'],
'operator_user_id' => auth()->id(),
'metadata' => [
'stage_1' => [
'wash_date' => $validated['wash_date'],
'cultivator' => $inputBatch->cultivator ?? 'Unknown',
'starting_weight' => (float) $validated['starting_weight'],
'soak_time_minutes' => (int) $validated['soak_time_minutes'],
'room_temperature_f' => (float) $validated['room_temperature_f'],
'vessel_temperature_f' => (float) $validated['vessel_temperature_f'],
'strain' => $validated['strain'],
'wash_cycles' => $validated['wash_cycles'],
],
],
'notes' => $validated['notes'],
]);
// Link input batch to conversion
$conversion->inputBatches()->attach($inputBatch->id, [
'role' => 'input',
'quantity_used' => $validated['starting_weight'],
'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

@@ -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,147 @@
<?php
namespace App\Jobs\Analytics;
use App\Events\Analytics\BuyerEngagementUpdated;
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,
]);
// Store previous score for comparison
$previousScore = $score->exists ? $score->score : null;
// 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();
// Broadcast engagement score updates for significant changes
// Only broadcast if score changed by ≥10 points or it's a new score
if ($previousScore === null || abs($score->score - $previousScore) >= 10) {
broadcast(new BuyerEngagementUpdated(
$this->businessId,
$this->buyerBusinessId,
$score,
$previousScore
));
}
}
}

View File

@@ -0,0 +1,125 @@
<?php
namespace App\Jobs\Analytics;
use App\Models\Analytics\IntentSignal;
use App\Models\Analytics\ProductView;
use App\Models\Product;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class ProcessIntentSignalJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected int $businessId;
protected int $userId;
protected int $buyerBusinessId;
protected Product $product;
protected ProductView $view;
public function __construct(
int $businessId,
int $userId,
int $buyerBusinessId,
Product $product,
ProductView $view
) {
$this->businessId = $businessId;
$this->userId = $userId;
$this->buyerBusinessId = $buyerBusinessId;
$this->product = $product;
$this->view = $view;
$this->onQueue('analytics');
}
public function handle(): void
{
// Check for repeated view signal
$previousViews = ProductView::where('business_id', $this->businessId)
->where('viewer_id', $this->userId)
->where('product_id', $this->product->id)
->where('viewed_at', '>', now()->subDays(7))
->count();
if ($previousViews >= 2) {
IntentSignal::create([
'business_id' => $this->businessId,
'user_id' => $this->userId,
'buyer_business_id' => $this->buyerBusinessId,
'signal_type' => 'repeated_view',
'signal_strength' => min(10, $previousViews + 1),
'context_type' => Product::class,
'context_id' => $this->product->id,
'detected_at' => now(),
'metadata' => [
'total_views' => $previousViews + 1,
'days_span' => 7,
],
]);
}
// Check for high engagement signal
$engagementScore = $this->view->getEngagementScore();
if ($engagementScore >= 60) {
IntentSignal::create([
'business_id' => $this->businessId,
'user_id' => $this->userId,
'buyer_business_id' => $this->buyerBusinessId,
'signal_type' => 'high_engagement',
'signal_strength' => intval($engagementScore / 10),
'context_type' => Product::class,
'context_id' => $this->product->id,
'detected_at' => now(),
'metadata' => [
'engagement_score' => $engagementScore,
'actions' => [
'zoomed_image' => $this->view->zoomed_image,
'watched_video' => $this->view->watched_video,
'downloaded_specs' => $this->view->downloaded_specs ?? false,
],
],
]);
}
// Check for spec download signal (strong intent)
if ($this->view->downloaded_specs ?? false) {
IntentSignal::create([
'business_id' => $this->businessId,
'user_id' => $this->userId,
'buyer_business_id' => $this->buyerBusinessId,
'signal_type' => 'spec_download',
'signal_strength' => 9,
'context_type' => Product::class,
'context_id' => $this->product->id,
'detected_at' => now(),
'metadata' => ['action' => 'downloaded_specs'],
]);
}
// Check for contact click signal (very strong intent)
if ($this->view->clicked_contact ?? false) {
IntentSignal::create([
'business_id' => $this->businessId,
'user_id' => $this->userId,
'buyer_business_id' => $this->buyerBusinessId,
'signal_type' => 'contact_click',
'signal_strength' => 10,
'context_type' => Product::class,
'context_id' => $this->product->id,
'detected_at' => now(),
'metadata' => ['action' => 'clicked_contact'],
]);
}
// After processing intent signals, trigger engagement score recalculation
CalculateEngagementScore::dispatch($this->businessId, $this->buyerBusinessId);
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Jobs\Analytics;
use App\Models\Analytics\EmailCampaign;
use App\Models\Analytics\EmailInteraction;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class UpdateCampaignStatsJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected int $campaignId;
protected int $businessId;
public function __construct(int $campaignId, int $businessId)
{
$this->campaignId = $campaignId;
$this->businessId = $businessId;
$this->onQueue('analytics');
}
public function handle(): void
{
$campaign = EmailCampaign::where('id', $this->campaignId)
->where('business_id', $this->businessId)
->first();
if (! $campaign) {
return;
}
// Get all interactions for this campaign
$interactions = EmailInteraction::where('campaign_id', $this->campaignId)
->where('business_id', $this->businessId)
->get();
// Calculate stats
$totalSent = $interactions->whereNotNull('sent_at')->count();
$totalDelivered = $interactions->whereNotNull('delivered_at')->count();
$totalBounced = $interactions->whereNotNull('bounced_at')->count();
// Count unique opens (recipients who opened at least once)
$totalOpened = $interactions->whereNotNull('first_opened_at')->count();
// Count unique clicks (recipients who clicked at least once)
$totalClicked = $interactions->whereNotNull('first_clicked_at')->count();
// Update campaign stats
$campaign->update([
'total_sent' => $totalSent,
'total_delivered' => $totalDelivered,
'total_bounced' => $totalBounced,
'total_opened' => $totalOpened,
'total_clicked' => $totalClicked,
]);
}
}

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,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,56 @@
<?php
namespace App\Listeners;
use App\Events\Analytics\HighIntentSignalDetected;
use App\Models\Business;
use App\Models\User;
use App\Notifications\Analytics\HighIntentSignalNotification;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Notification;
class SendHighIntentSignalPushNotification implements ShouldQueue
{
use InteractsWithQueue;
/**
* Create the event listener.
*/
public function __construct()
{
//
}
/**
* Handle the event.
*/
public function handle(HighIntentSignalDetected $event): void
{
// Get the business
$business = Business::find($event->businessId);
if (! $business) {
return;
}
// Find all users who have push subscriptions for this business
// (owners, admins who should receive hot lead alerts)
$users = User::whereHas('businesses', function ($query) use ($event) {
$query->where('businesses.id', $event->businessId);
})
->whereHas('roles', function ($query) {
$query->whereIn('name', ['owner', 'admin', 'super_admin']);
})
->get();
// Send push notification to each user
foreach ($users as $user) {
$user->notify(new HighIntentSignalNotification(
$business,
$event->signal,
$event->engagementScore
));
}
}
}

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 = true;
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,118 @@
<?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);
});
}
// Helpers
public function getEngagementScore(): int
{
$score = 0;
// Time on page (max 40 points)
$score += min(40, ($this->time_on_page / 3)); // 1 point per 3 seconds
// Engagement actions (10 points each)
if ($this->zoomed_image) {
$score += 10;
}
if ($this->watched_video) {
$score += 10;
}
if ($this->downloaded_specs ?? false) {
$score += 15;
}
if ($this->clicked_contact ?? false) {
$score += 15;
}
if ($this->added_to_cart) {
$score += 20;
}
// Scroll depth (max 10 points)
$score += ($this->scroll_depth / 10);
return min(100, (int) $score);
}
}

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();
}
}

View File

@@ -2,40 +2,48 @@
namespace App\Models;
use App\Traits\BelongsToBusinessViaProduct;
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;
/**
* DEFINITIVE BATCH MODEL - See BATCH_AND_LAB_SYSTEM.md
*
* Two types of batches:
* 1. Component batches: Tested input material (flower, rosin, etc.)
* 2. Homogenized batches: Mixed components requiring new testing
*/
class Batch extends Model
{
use BelongsToBusinessViaProduct, HasFactory, SoftDeletes;
use HasFactory, SoftDeletes;
protected $fillable = [
'product_id',
'business_id',
'batch_type',
'component_id',
'batch_number',
'internal_code',
'quantity_total',
'quantity_remaining',
'quantity_unit',
'primary_coa_id',
'production_date',
'harvest_date',
'package_date',
'test_date',
'expiration_date',
'quantity_produced',
'quantity_available',
'quantity_allocated',
'quantity_sold',
'is_active',
'is_tested',
'is_quarantined',
'qr_code_path',
'notes',
'metadata',
];
protected $casts = [
'production_date' => 'date',
'harvest_date' => 'date',
'package_date' => 'date',
'test_date' => 'date',
'expiration_date' => 'date',
'is_active' => 'boolean',
'is_tested' => 'boolean',
@@ -44,154 +52,177 @@ class Batch extends Model
];
/**
* Relationships
* Business that owns this batch
*/
public function product(): BelongsTo
public function business(): BelongsTo
{
return $this->belongsTo(Product::class);
return $this->belongsTo(Business::class);
}
/**
* Component (for component batches only)
*/
public function component(): BelongsTo
{
return $this->belongsTo(Component::class);
}
/**
* Primary COA/Lab test for this batch
*/
public function primaryCoa(): BelongsTo
{
return $this->belongsTo(Lab::class, 'primary_coa_id');
}
/**
* All lab tests for this batch
*/
public function labs(): HasMany
{
return $this->hasMany(Lab::class);
}
public function orderItems(): HasMany
/**
* SKU variants that use this batch (via pivot)
*/
public function products(): BelongsToMany
{
return $this->hasMany(OrderItem::class);
return $this->belongsToMany(Product::class, 'product_batches')
->withPivot('quantity_allocated', 'is_active')
->withTimestamps();
}
/**
* Scopes
* For homogenized batches: source component batches used
*/
public function sourceComponents(): BelongsToMany
{
return $this->belongsToMany(
Batch::class,
'batch_source_components',
'homogenized_batch_id',
'source_batch_id'
)->withPivot('quantity_used', 'unit')
->withTimestamps();
}
/**
* For component batches: homogenized batches that used this as source
*/
public function usedInHomogenizedBatches(): BelongsToMany
{
return $this->belongsToMany(
Batch::class,
'batch_source_components',
'source_batch_id',
'homogenized_batch_id'
)->withPivot('quantity_used', 'unit')
->withTimestamps();
}
/**
* Scope: Component batches only
*/
public function scopeComponent($query)
{
return $query->where('batch_type', 'component');
}
/**
* Scope: Homogenized batches only
*/
public function scopeHomogenized($query)
{
return $query->where('batch_type', 'homogenized');
}
/**
* Scope: Active batches
*/
public function scopeActive($query)
{
return $query->where('is_active', true)
->where('is_quarantined', false);
}
public function scopeAvailable($query)
{
return $query->active()
->where('quantity_available', '>', 0);
return $query->where('is_active', true);
}
/**
* Scope: Tested batches
*/
public function scopeTested($query)
{
return $query->where('is_tested', true);
}
public function scopeExpired($query)
/**
* Check if batch is component type
*/
public function isComponent(): bool
{
return $query->whereNotNull('expiration_date')
->where('expiration_date', '<', now());
return $this->batch_type === 'component';
}
/**
* Helper methods
* Check if batch is homogenized type
*/
/**
* Check if batch is available for purchase
*/
public function isAvailableForPurchase(): bool
public function isHomogenized(): bool
{
return $this->is_active
&& ! $this->is_quarantined
&& $this->quantity_available > 0
&& ($this->expiration_date === null || $this->expiration_date > now());
return $this->batch_type === 'homogenized';
}
/**
* Get the most recent lab test for this batch
* Check if batch is depleted
*/
public function getLatestLab()
public function isDepleted(): bool
{
return $this->labs()->latest('test_date')->first();
return $this->quantity_remaining <= 0;
}
/**
* Allocate quantity for an order (reserves inventory)
* Get all COAs for this batch (primary + source COAs for homogenized)
*/
public function allocate(int $quantity): bool
public function getAllCoas()
{
if ($this->quantity_available < $quantity) {
$coas = collect();
// Add primary COA
if ($this->primaryCoa) {
$coas->push([
'type' => 'primary',
'coa' => $this->primaryCoa,
]);
}
// If homogenized, add source component COAs
if ($this->isHomogenized()) {
foreach ($this->sourceComponents as $sourceComponent) {
if ($sourceComponent->primaryCoa) {
$coas->push([
'type' => 'source',
'coa' => $sourceComponent->primaryCoa,
'component' => $sourceComponent,
]);
}
}
}
return $coas;
}
/**
* Reduce quantity remaining
*/
public function consume(int $quantity): bool
{
if ($this->quantity_remaining < $quantity) {
return false;
}
$this->decrement('quantity_available', $quantity);
$this->increment('quantity_allocated', $quantity);
$this->quantity_remaining -= $quantity;
$this->save();
return true;
}
/**
* Release allocated quantity (e.g., when order is cancelled)
*/
public function releaseAllocation(int $quantity): void
{
$this->increment('quantity_available', $quantity);
$this->decrement('quantity_allocated', $quantity);
}
/**
* Mark quantity as sold (moves from allocated to sold)
*/
public function markSold(int $quantity): void
{
$this->decrement('quantity_allocated', $quantity);
$this->increment('quantity_sold', $quantity);
}
/**
* Get batch availability summary
*/
public function getAvailabilitySummary(): array
{
return [
'total_produced' => $this->quantity_produced,
'available' => $this->quantity_available,
'allocated' => $this->quantity_allocated,
'sold' => $this->quantity_sold,
'remaining_percentage' => $this->quantity_produced > 0
? round(($this->quantity_available / $this->quantity_produced) * 100, 1)
: 0,
];
}
/**
* Check if batch is expiring soon (within 30 days)
*/
public function isExpiringSoon(): bool
{
if (! $this->expiration_date) {
return false;
}
return $this->expiration_date <= now()->addDays(30)
&& $this->expiration_date > now();
}
/**
* Check if batch is expired
*/
public function isExpired(): bool
{
if (! $this->expiration_date) {
return false;
}
return $this->expiration_date < now();
}
/**
* Get the primary lab test (most recent or most complete)
*/
public function getPrimaryLabAttribute()
{
return $this->getLatestLab();
}
/**
* Format batch number for display
*/

View File

@@ -32,21 +32,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 +174,6 @@ class Brand extends Model
->get();
}
/**
* Get route key (slug for URLs)
*/
public function getRouteKeyName(): string
{
return 'slug';
}
/**
* Generate slug from name
*/
@@ -178,6 +182,36 @@ class Brand extends Model
return Str::slug($this->name);
}
/**
* Generate a unique 5-character hashid
*/
public function generateHashid(): string
{
do {
$hashid = Str::random(5);
} while (self::where('hashid', $hashid)->exists());
return $hashid;
}
/**
* Get the route key name for Laravel route model binding
* Brands use hashid for routing (like products)
*/
public function getRouteKeyName(): string
{
return 'hashid';
}
/**
* 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
*/
@@ -217,13 +251,54 @@ class Brand extends Model
}
/**
* Boot method to auto-generate slug
* Check if brand has a banner
*/
public function hasBanner(): bool
{
return ! empty($this->banner_path) && \Storage::disk('public')->exists($this->banner_path);
}
/**
* Get public URL for the brand banner
*/
public function getBannerUrl(): ?string
{
if (! $this->banner_path) {
return null;
}
return asset('storage/'.$this->banner_path);
}
/**
* Delete banner file from storage
*/
public function deleteBannerFile(): bool
{
if (! $this->banner_path) {
return false;
}
$disk = \Storage::disk('public');
if ($disk->exists($this->banner_path)) {
return $disk->delete($this->banner_path);
}
return false;
}
/**
* Boot method to auto-generate slug and hashid
*/
protected static function boot()
{
parent::boot();
static::creating(function ($brand) {
if (empty($brand->hashid)) {
$brand->hashid = $brand->generateHashid();
}
if (empty($brand->slug)) {
$brand->slug = $brand->generateSlug();
}

View File

@@ -38,6 +38,15 @@ class Business extends Model implements AuditableContract
return substr($fullUuid, 0, 18); // 18 chars total (16 hex + 2 hyphens)
}
/**
* Get the route key name for Laravel route model binding
* Businesses use slug for routing
*/
public function getRouteKeyName(): string
{
return 'slug';
}
// User type (buyer/seller/both)
public const TYPES = [
'buyer' => 'Buyer (Dispensary/Retailer)',
@@ -172,6 +181,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,9 +228,25 @@ 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
// Relationships
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class, 'business_user')
@@ -274,7 +332,7 @@ class Business extends Model implements AuditableContract
return $query->whereIn('type', ['buyer', 'both']);
}
// Helper methods (LeafLink-aligned)
// Helper methods
public function isSeller(): bool
{
return in_array($this->type, ['seller', 'both']);

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

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

@@ -0,0 +1,250 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Conversion extends Model
{
use SoftDeletes;
protected $fillable = [
'business_id',
'conversion_type',
'status',
'internal_name',
'started_at',
'submitted_to_lab_at',
'completed_at',
'operator_user_id',
'expected_output_quantity',
'expected_output_unit',
'actual_output_quantity',
'actual_output_unit',
'batch_created_id',
'metadata',
'notes',
];
protected $casts = [
'started_at' => 'datetime',
'submitted_to_lab_at' => 'datetime',
'completed_at' => 'datetime',
'metadata' => 'array',
'expected_output_quantity' => 'decimal:2',
'actual_output_quantity' => 'decimal:2',
];
// Relationships
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function operator(): BelongsTo
{
return $this->belongsTo(User::class, 'operator_user_id');
}
public function batchCreated(): BelongsTo
{
return $this->belongsTo(Batch::class, 'batch_created_id');
}
/**
* Input batches consumed in this conversion
*/
public function inputBatches(): BelongsToMany
{
return $this->belongsToMany(Batch::class, 'conversion_batches')
->wherePivot('role', 'input')
->withPivot(['quantity_used', 'unit'])
->withTimestamps();
}
/**
* Output batches produced from this conversion
*/
public function outputBatches(): BelongsToMany
{
return $this->belongsToMany(Batch::class, 'conversion_batches')
->wherePivot('role', 'output')
->withPivot(['quantity_produced', 'unit'])
->withTimestamps();
}
// Scopes
public function scopeInProgress($query)
{
return $query->where('status', 'in_progress');
}
public function scopePendingLab($query)
{
return $query->where('status', 'pending_lab');
}
public function scopePassed($query)
{
return $query->where('status', 'passed');
}
public function scopeFailed($query)
{
return $query->where('status', 'failed');
}
public function scopeForBusiness($query, $businessId)
{
return $query->where('business_id', $businessId);
}
public function scopeHashWash($query)
{
return $query->where('conversion_type', 'hash_wash');
}
// Helper Methods
public function isInProgress(): bool
{
return $this->status === 'in_progress';
}
public function isPendingLab(): bool
{
return $this->status === 'pending_lab';
}
public function hasPassed(): bool
{
return $this->status === 'passed';
}
public function hasFailed(): bool
{
return $this->status === 'failed';
}
/**
* Get Stage 1 wash parameters from metadata
*/
public function getStage1Data(): ?array
{
return $this->metadata['stage_1'] ?? null;
}
/**
* Get Stage 2 yield data from metadata
*/
public function getStage2Data(): ?array
{
return $this->metadata['stage_2'] ?? null;
}
/**
* Get wash cycles from Stage 1 metadata
*/
public function getWashCycles(): array
{
return $this->metadata['stage_1']['wash_cycles'] ?? [];
}
/**
* Get yield grades from Stage 2 metadata
*/
public function getYieldGrades(): array
{
return $this->metadata['stage_2']['yields'] ?? [];
}
/**
* Get total yield from Stage 2 metadata
*/
public function getTotalYield(): ?float
{
return $this->metadata['stage_2']['total_yield'] ?? null;
}
/**
* Calculate total yield percentage
*/
public function getYieldPercentage(): ?float
{
$stage1 = $this->getStage1Data();
$totalYield = $this->getTotalYield();
if (! $stage1 || ! $totalYield || ! isset($stage1['starting_weight'])) {
return null;
}
$startingWeight = $stage1['starting_weight'];
if ($startingWeight <= 0) {
return null;
}
return round(($totalYield / $startingWeight) * 100, 2);
}
/**
* Mark conversion as submitted to lab
*/
public function submitToLab(): bool
{
if (! $this->isInProgress()) {
return false;
}
$this->status = 'pending_lab';
$this->submitted_to_lab_at = now();
return $this->save();
}
/**
* Mark conversion as passed and create batch
*/
public function markAsPassed(Batch $batch): bool
{
if (! $this->isPendingLab()) {
return false;
}
$this->status = 'passed';
$this->completed_at = now();
$this->batch_created_id = $batch->id;
return $this->save();
}
/**
* Mark conversion as failed
*/
public function markAsFailed(string $failureReason): bool
{
if (! $this->isPendingLab()) {
return false;
}
$this->status = 'failed';
$this->completed_at = now();
$saved = $this->save();
if ($saved) {
// Create failed conversion record
FailedConversion::create([
'conversion_id' => $this->id,
'failed_at' => now(),
'failure_reason' => $failureReason,
]);
}
return $saved;
}
}

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,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,7 +15,7 @@ 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
@@ -23,8 +24,10 @@ class Product extends Model implements Auditable
'parent_product_id',
'packaging_id',
'unit_id',
'category_id',
// Product Identity
'hashid',
'name',
'slug',
'sku',
@@ -214,6 +217,11 @@ class Product extends Model implements Auditable
return $this->belongsTo(Unit::class);
}
public function category(): BelongsTo
{
return $this->belongsTo(ProductCategory::class, 'category_id');
}
public function parent(): BelongsTo
{
return $this->belongsTo(Product::class, 'parent_product_id');

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,54 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use NotificationChannels\WebPush\HasPushSubscriptions;
class PushSubscription extends Model
{
use HasFactory, HasPushSubscriptions;
protected $fillable = [
'user_id',
'business_id',
'endpoint',
'public_key',
'auth_token',
'content_encoding',
];
/**
* Get the user that owns the push subscription.
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* Get the business that owns the push subscription.
*/
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
/**
* Scope to subscriptions for a specific business
*/
public function scopeForBusiness($query, int $businessId)
{
return $query->where('business_id', $businessId);
}
/**
* Scope to subscriptions for a specific user
*/
public function scopeForUser($query, int $userId)
{
return $query->where('user_id', $userId);
}
}

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}";
}
}

View File

@@ -0,0 +1,124 @@
<?php
namespace App\Notifications\Analytics;
use App\Models\Analytics\BuyerEngagementScore;
use App\Models\Analytics\IntentSignal;
use App\Models\Business;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
use NotificationChannels\WebPush\WebPushChannel;
use NotificationChannels\WebPush\WebPushMessage;
class HighIntentSignalNotification extends Notification implements ShouldQueue
{
use Queueable;
/**
* Create a new notification instance.
*/
public function __construct(
public Business $business,
public IntentSignal $signal,
public ?BuyerEngagementScore $engagementScore = null
) {
$this->onQueue('notifications');
}
/**
* Get the notification's delivery channels.
*/
public function via(object $notifiable): array
{
return [WebPushChannel::class, 'database'];
}
/**
* Get the web push representation of the notification.
*/
public function toWebPush(object $notifiable): WebPushMessage
{
$buyerBusiness = $this->signal->buyerBusiness;
$user = $this->signal->user;
$title = $this->getTitle();
$body = $this->getBody();
return (new WebPushMessage)
->title($title)
->icon('/images/logo.png')
->body($body)
->action('View Analytics', 'analytics')
->data([
'business_id' => $this->business->id,
'buyer_business_id' => $buyerBusiness->id,
'signal_id' => $this->signal->id,
'signal_type' => $this->signal->signal_type,
'signal_strength' => $this->signal->signal_strength,
'url' => route('seller.analytics.index', $this->business->slug),
])
->badge('/images/badge.png')
->vibrate([200, 100, 200]);
}
/**
* Get the array representation of the notification.
*/
public function toArray(object $notifiable): array
{
$buyerBusiness = $this->signal->buyerBusiness;
return [
'type' => 'high_intent_signal',
'business_id' => $this->business->id,
'buyer_business_id' => $buyerBusiness->id,
'buyer_business_name' => $buyerBusiness->name,
'signal_id' => $this->signal->id,
'signal_type' => $this->signal->signal_type,
'signal_strength' => $this->signal->signal_strength,
'engagement_score' => $this->engagementScore?->score,
'score_tier' => $this->engagementScore?->score_tier,
'message' => $this->getBody(),
'url' => route('seller.analytics.index', $this->business->slug),
];
}
/**
* Get notification title based on signal type
*/
protected function getTitle(): string
{
return match ($this->signal->signal_type) {
'repeated_view' => 'Hot Lead Alert - Repeated Product View',
'high_engagement' => 'Hot Lead Alert - High Engagement',
'spec_download' => 'Hot Lead Alert - Spec Downloaded',
'contact_click' => 'Hot Lead Alert - Contact Clicked',
default => 'Hot Lead Alert',
};
}
/**
* Get notification body based on signal type
*/
protected function getBody(): string
{
$buyerBusiness = $this->signal->buyerBusiness;
$buyerName = $buyerBusiness->name;
$body = match ($this->signal->signal_type) {
'repeated_view' => "{$buyerName} has viewed your product multiple times!",
'high_engagement' => "{$buyerName} is highly engaged with your products!",
'spec_download' => "{$buyerName} downloaded product specifications!",
'contact_click' => "{$buyerName} clicked to contact you!",
default => "{$buyerName} showed high purchase intent!",
};
// Add engagement score if available
if ($this->engagementScore) {
$body .= " (Engagement: {$this->engagementScore->score}/100 - {$this->engagementScore->score_tier})";
}
return $body;
}
}

View File

@@ -2,12 +2,15 @@
namespace App\Providers;
use App\Events\Analytics\HighIntentSignalDetected;
use App\Listeners\SendHighIntentSignalPushNotification;
use App\Models\Brand;
use App\Models\Business;
use App\Models\User;
use App\Observers\BrandObserver;
use App\Observers\CompanyObserver;
use App\Observers\UserObserver;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
@@ -44,6 +47,12 @@ class AppServiceProvider extends ServiceProvider
Business::observe(CompanyObserver::class);
Brand::observe(BrandObserver::class);
// Register event listener for high-intent signals
Event::listen(
HighIntentSignalDetected::class,
SendHighIntentSignalPushNotification::class
);
// Cache version info to avoid running git commands on every view render
// This was causing massive performance issues (3-5s page loads)
//

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Gate;
use Laravel\Horizon\Horizon;
use Laravel\Horizon\HorizonApplicationServiceProvider;
class HorizonServiceProvider extends HorizonApplicationServiceProvider
{
/**
* Bootstrap any application services.
*/
public function boot(): void
{
parent::boot();
// Horizon::routeSmsNotificationsTo('15556667777');
// Horizon::routeMailNotificationsTo('example@example.com');
// Horizon::routeSlackNotificationsTo('slack-webhook-url', '#channel');
}
/**
* Register the Horizon gate.
*
* This gate determines who can access Horizon in non-local environments.
*/
protected function gate(): void
{
Gate::define('viewHorizon', function ($user = null) {
if (! $user) {
return false;
}
// Super admins can access Horizon
if ($user->hasRole('super_admin')) {
return true;
}
// Business owners and admins can access Horizon
return $user->hasAnyRole(['owner', 'admin']);
});
}
}

View File

@@ -0,0 +1,263 @@
<?php
namespace App\Services\Analytics;
use App\Models\Analytics\AnalyticsEvent;
use App\Models\Analytics\ProductView;
use App\Models\Analytics\UserSession;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Session;
class AnalyticsTracker
{
protected $request;
protected $sessionId;
protected $fingerprint;
public function __construct(Request $request)
{
$this->request = $request;
$this->sessionId = Session::getId();
$this->fingerprint = $this->generateFingerprint();
}
/**
* Track generic analytics event
*/
public function track(string $eventType, array $data = []): ?AnalyticsEvent
{
$businessId = $data['business_id'] ?? currentBusinessId();
if (! $businessId) {
return null; // Can't track without business context
}
return AnalyticsEvent::create(array_merge([
'business_id' => $businessId,
'event_type' => $eventType,
'session_id' => $this->sessionId,
'fingerprint' => $this->fingerprint,
'user_id' => auth()->id(),
'url' => $this->request->fullUrl(),
'referrer' => $this->request->header('referer'),
'user_agent' => $this->request->userAgent(),
'ip_address' => $this->request->ip(),
'device_type' => $this->detectDeviceType(),
], $this->extractUtmParams(), $data));
}
/**
* Track product view
* IMPORTANT: Product doesn't have business_id, get from Brand
*/
public function trackProductView($product, array $additionalData = []): ?ProductView
{
$sellerBusiness = $product->brand?->business;
if (! $sellerBusiness) {
return null;
}
// Determine buyer's business if logged in
$buyerBusinessId = null;
if (auth()->check() && auth()->user()->user_type === 'buyer') {
$buyerBusinessId = currentBusinessId();
}
// Create generic event
$this->track('product_view', [
'business_id' => $sellerBusiness->id,
'event_category' => 'product',
'event_action' => 'view',
'subject_id' => $product->id,
'subject_type' => 'Product',
]);
// Create detailed product view
return ProductView::create(array_merge([
'business_id' => $sellerBusiness->id, // Seller's business
'product_id' => $product->id,
'user_id' => auth()->id(),
'buyer_business_id' => $buyerBusinessId,
'session_id' => $this->sessionId,
'viewed_at' => now(),
'source' => $this->determineSource(),
'referrer' => $this->request->header('referer'),
'device_type' => $this->detectDeviceType(),
], $this->extractUtmParams(), $additionalData));
}
/**
* Get or create session for current user
*/
public function getOrCreateSession(): ?UserSession
{
$businessId = currentBusinessId();
if (! $businessId) {
return null;
}
return UserSession::firstOrCreate(
['session_id' => $this->sessionId],
[
'business_id' => $businessId,
'user_id' => auth()->id(),
'buyer_business_id' => auth()->user()?->user_type === 'buyer' ? currentBusinessId() : null,
'started_at' => now(),
'landing_page' => $this->request->fullUrl(),
'referrer' => $this->request->header('referer'),
'device_type' => $this->detectDeviceType(),
'browser' => $this->detectBrowser(),
'os' => $this->detectOS(),
'ip_address' => $this->request->ip(),
] + $this->extractUtmParams()
);
}
/**
* Increment session counter (page views, product views, etc.)
*/
public function incrementSessionCounter(string $counter): void
{
$session = $this->getOrCreateSession();
if ($session) {
$session->increment($counter);
}
}
/**
* Mark session as converted
*/
public function markSessionAsConverted(?float $value = null): void
{
$session = $this->getOrCreateSession();
if ($session) {
$session->update([
'converted' => true,
'conversion_value' => $value,
'ended_at' => now(),
]);
}
}
/**
* Extract UTM parameters from request
*/
protected function extractUtmParams(): array
{
return [
'utm_source' => $this->request->get('utm_source'),
'utm_medium' => $this->request->get('utm_medium'),
'utm_campaign' => $this->request->get('utm_campaign'),
];
}
/**
* Determine traffic source
*/
protected function determineSource(): ?string
{
$referrer = $this->request->header('referer');
if (! $referrer) {
return 'direct';
}
if ($this->request->has('utm_source')) {
return $this->request->get('utm_source');
}
if (preg_match('/google|bing|yahoo|duckduckgo/i', $referrer)) {
return 'search';
}
if ($this->request->has('email_token')) {
return 'email';
}
return 'referral';
}
/**
* Generate browser fingerprint
*/
protected function generateFingerprint(): string
{
$components = [
$this->request->userAgent(),
$this->request->header('accept-language'),
$this->request->header('accept-encoding'),
];
return hash('sha256', implode('|', $components));
}
/**
* Detect device type from user agent
*/
protected function detectDeviceType(): string
{
$userAgent = $this->request->userAgent();
if (preg_match('/mobile/i', $userAgent)) {
return 'mobile';
}
if (preg_match('/tablet|ipad/i', $userAgent)) {
return 'tablet';
}
return 'desktop';
}
/**
* Detect browser from user agent
*/
protected function detectBrowser(): ?string
{
$userAgent = $this->request->userAgent();
if (preg_match('/edg/i', $userAgent)) {
return 'Edge';
}
if (preg_match('/chrome/i', $userAgent)) {
return 'Chrome';
}
if (preg_match('/safari/i', $userAgent)) {
return 'Safari';
}
if (preg_match('/firefox/i', $userAgent)) {
return 'Firefox';
}
if (preg_match('/opera|opr/i', $userAgent)) {
return 'Opera';
}
return null;
}
/**
* Detect operating system from user agent
*/
protected function detectOS(): ?string
{
$userAgent = $this->request->userAgent();
if (preg_match('/windows/i', $userAgent)) {
return 'Windows';
}
if (preg_match('/mac os/i', $userAgent)) {
return 'MacOS';
}
if (preg_match('/linux/i', $userAgent)) {
return 'Linux';
}
if (preg_match('/android/i', $userAgent)) {
return 'Android';
}
if (preg_match('/ios|iphone|ipad/i', $userAgent)) {
return 'iOS';
}
return null;
}
}

View File

@@ -0,0 +1,392 @@
<?php
namespace App\Services;
use App\Events\Analytics\HighIntentSignalDetected;
use App\Events\Analytics\NewAnalyticsEvent;
use App\Models\Analytics\AnalyticsEvent;
use App\Models\Analytics\BuyerEngagementScore;
use App\Models\Analytics\ClickTracking;
use App\Models\Analytics\IntentSignal;
use App\Models\Analytics\ProductView;
use App\Models\Analytics\UserSession;
use App\Models\Business;
use App\Models\Product;
use App\Models\User;
use Illuminate\Support\Facades\Request;
use Illuminate\Support\Str;
class AnalyticsTracker
{
protected string $sessionId;
protected ?User $user;
protected ?Business $business;
public function __construct()
{
$this->sessionId = session()->getId() ?: Str::random(32);
$this->user = auth()->user();
$this->business = \App\Helpers\BusinessHelper::current();
}
/**
* Track product view with full engagement metrics
*/
public function trackProductView(
Product $product,
array $engagementData = []
): ProductView {
// Get seller's business from product
$sellerBusiness = $product->brand->business;
$view = ProductView::create([
'business_id' => $sellerBusiness->id,
'product_id' => $product->id,
'viewer_id' => $this->user?->id,
'viewer_business_id' => $this->business?->id,
'session_id' => $this->sessionId,
'viewed_at' => now(),
'source' => $engagementData['source'] ?? $this->detectSource(),
'referrer_url' => Request::header('referer'),
'device_type' => $this->detectDeviceType(),
'browser' => $this->detectBrowser(),
// Engagement metrics
'time_on_page' => $engagementData['time_on_page'] ?? 0,
'scroll_depth' => $engagementData['scroll_depth'] ?? 0,
'zoomed_image' => $engagementData['zoomed_image'] ?? false,
'watched_video' => $engagementData['watched_video'] ?? false,
'downloaded_specs' => $engagementData['downloaded_specs'] ?? false,
'clicked_contact' => $engagementData['clicked_contact'] ?? false,
'added_to_cart' => $engagementData['added_to_cart'] ?? false,
]);
// Also create generic analytics event
$this->trackEvent($sellerBusiness->id, 'product_view', $product, [
'product_name' => $product->name,
'brand_name' => $product->brand->name,
'viewer_business' => $this->business?->name,
]);
// Update session
$this->updateSession($sellerBusiness->id);
// Check for intent signals
$this->detectIntentSignals($sellerBusiness->id, $product, $view);
return $view;
}
/**
* Track any generic analytics event
*/
public function trackEvent(
int $businessId,
string $eventType,
$trackable = null,
array $properties = []
): AnalyticsEvent {
$event = AnalyticsEvent::create([
'business_id' => $businessId,
'user_id' => $this->user?->id,
'event_type' => $eventType,
'trackable_type' => $trackable ? get_class($trackable) : null,
'trackable_id' => $trackable?->id,
'properties' => $properties,
'session_id' => $this->sessionId,
'ip_address' => Request::ip(),
'user_agent' => Request::userAgent(),
'referer' => Request::header('referer'),
'created_at' => now(),
'updated_at' => now(),
]);
// Broadcast important events for real-time dashboard
$importantEvents = [
'product_view',
'add_to_cart',
'order_placed',
'contact_clicked',
'spec_downloaded',
'video_watched',
];
if (in_array($eventType, $importantEvents)) {
broadcast(new NewAnalyticsEvent($businessId, $event));
}
return $event;
}
/**
* Track click
*/
public function trackClick(
int $businessId,
string $elementType,
?string $elementId = null,
?string $elementText = null,
?string $destinationUrl = null,
array $metadata = []
): ClickTracking {
$click = ClickTracking::create([
'business_id' => $businessId,
'user_id' => $this->user?->id,
'element_type' => $elementType,
'element_id' => $elementId,
'element_text' => $elementText,
'destination_url' => $destinationUrl,
'page_url' => Request::url(),
'session_id' => $this->sessionId,
'metadata' => $metadata,
]);
// Update session
$this->updateSession($businessId);
return $click;
}
/**
* Update or create session
*/
protected function updateSession(?int $businessId = null): void
{
$session = UserSession::firstOrCreate(
['session_id' => $this->sessionId],
[
'user_id' => $this->user?->id,
'business_id' => $businessId ?? $this->business?->id,
'started_at' => now(),
'last_activity_at' => now(),
'landing_page' => Request::url(),
'referrer' => Request::header('referer'),
'ip_address' => Request::ip(),
'device_type' => $this->detectDeviceType(),
'browser' => $this->detectBrowser(),
'os' => $this->detectOS(),
]
);
// Update activity
$session->update([
'last_activity_at' => now(),
'duration_seconds' => (int) $session->started_at->diffInSeconds(now()),
'page_views' => $session->page_views + 1,
'exit_page' => Request::url(),
]);
}
/**
* Detect intent signals from product view
*/
protected function detectIntentSignals(int $businessId, Product $product, ProductView $view): void
{
if (! $this->user || ! $this->business) {
return;
}
// Get current engagement score for broadcasting
$engagementScore = BuyerEngagementScore::where('business_id', $businessId)
->where('buyer_business_id', $this->business->id)
->first();
// Repeated view signal
$previousViews = ProductView::where('business_id', $businessId)
->where('viewer_id', $this->user->id)
->where('product_id', $product->id)
->where('created_at', '>', now()->subDays(7))
->count();
if ($previousViews >= 2) {
$signal = IntentSignal::create([
'business_id' => $businessId,
'user_id' => $this->user->id,
'buyer_business_id' => $this->business->id,
'signal_type' => 'repeated_view',
'signal_strength' => min(10, $previousViews + 1),
'context_type' => Product::class,
'context_id' => $product->id,
'metadata' => [
'total_views' => $previousViews + 1,
'days_span' => 7,
],
]);
// Broadcast high intent signal for hot lead alerts
if ($signal->signal_strength >= 8) {
broadcast(new HighIntentSignalDetected(
$businessId,
$this->business->id,
$signal,
$engagementScore
));
}
}
// High engagement signal
$viewEngagementScore = $view->getEngagementScore();
if ($viewEngagementScore >= 60) {
$signal = IntentSignal::create([
'business_id' => $businessId,
'user_id' => $this->user->id,
'buyer_business_id' => $this->business->id,
'signal_type' => 'high_engagement',
'signal_strength' => intval($viewEngagementScore / 10),
'context_type' => Product::class,
'context_id' => $product->id,
'metadata' => [
'engagement_score' => $viewEngagementScore,
'actions' => [
'zoomed_image' => $view->zoomed_image,
'watched_video' => $view->watched_video,
'downloaded_specs' => $view->downloaded_specs,
],
],
]);
// Broadcast high intent signal for hot lead alerts
if ($signal->signal_strength >= 8) {
broadcast(new HighIntentSignalDetected(
$businessId,
$this->business->id,
$signal,
$engagementScore
));
}
}
// Downloaded specs = strong intent
if ($view->downloaded_specs) {
$signal = IntentSignal::create([
'business_id' => $businessId,
'user_id' => $this->user->id,
'buyer_business_id' => $this->business->id,
'signal_type' => 'spec_download',
'signal_strength' => 9,
'context_type' => Product::class,
'context_id' => $product->id,
'metadata' => ['action' => 'downloaded_specs'],
]);
// Broadcast high intent signal (strength = 9)
broadcast(new HighIntentSignalDetected(
$businessId,
$this->business->id,
$signal,
$engagementScore
));
}
// Clicked contact = very strong intent
if ($view->clicked_contact) {
$signal = IntentSignal::create([
'business_id' => $businessId,
'user_id' => $this->user->id,
'buyer_business_id' => $this->business->id,
'signal_type' => 'contact_click',
'signal_strength' => 10,
'context_type' => Product::class,
'context_id' => $product->id,
'metadata' => ['action' => 'clicked_contact'],
]);
// Broadcast high intent signal (strength = 10)
broadcast(new HighIntentSignalDetected(
$businessId,
$this->business->id,
$signal,
$engagementScore
));
}
}
/**
* Detect traffic source
*/
protected function detectSource(): string
{
$referer = Request::header('referer');
if (! $referer) {
return 'direct';
}
if (str_contains($referer, 'google')) {
return 'google';
}
if (str_contains($referer, 'email') || Request::has('utm_source')) {
return 'email';
}
return 'referral';
}
/**
* Detect device type
*/
protected function detectDeviceType(): string
{
$userAgent = Request::userAgent();
if (preg_match('/mobile|android|iphone|ipad|tablet/i', $userAgent)) {
return 'mobile';
}
return 'desktop';
}
/**
* Detect browser
*/
protected function detectBrowser(): string
{
$userAgent = Request::userAgent();
if (str_contains($userAgent, 'Chrome')) {
return 'Chrome';
}
if (str_contains($userAgent, 'Firefox')) {
return 'Firefox';
}
if (str_contains($userAgent, 'Safari')) {
return 'Safari';
}
if (str_contains($userAgent, 'Edge')) {
return 'Edge';
}
if (str_contains($userAgent, 'Opera')) {
return 'Opera';
}
return 'Other';
}
/**
* Detect operating system
*/
protected function detectOS(): string
{
$userAgent = Request::userAgent();
if (str_contains($userAgent, 'Windows')) {
return 'Windows';
}
if (str_contains($userAgent, 'Mac OS')) {
return 'macOS';
}
if (str_contains($userAgent, 'Linux')) {
return 'Linux';
}
if (str_contains($userAgent, 'Android')) {
return 'Android';
}
if (str_contains($userAgent, 'iOS')) {
return 'iOS';
}
return 'Other';
}
}

View File

@@ -0,0 +1,228 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Log;
class ImageBackgroundRemovalService
{
/**
* Remove background from an image
* Works best with simple/solid backgrounds
*
* @param string $imagePath Full path to the image file
* @return string|null Path to the processed image, or null on failure
*/
public function removeBackground(string $imagePath): ?string
{
try {
// Check if GD is available
if (! extension_loaded('gd')) {
Log::warning('GD extension not loaded, skipping background removal');
return $imagePath; // Return original if GD not available
}
// Get image info
$imageInfo = getimagesize($imagePath);
if (! $imageInfo) {
Log::warning("Could not read image: {$imagePath}");
return $imagePath;
}
$mimeType = $imageInfo['mime'];
// Load image based on type
$sourceImage = match ($mimeType) {
'image/jpeg', 'image/jpg' => imagecreatefromjpeg($imagePath),
'image/png' => imagecreatefrompng($imagePath),
default => null
};
if (! $sourceImage) {
Log::warning("Unsupported image type: {$mimeType}");
return $imagePath;
}
$width = imagesx($sourceImage);
$height = imagesy($sourceImage);
// Create a new transparent image
$transparentImage = imagecreatetruecolor($width, $height);
imagealphablending($transparentImage, false);
imagesavealpha($transparentImage, true);
// Make it fully transparent
$transparent = imagecolorallocatealpha($transparentImage, 0, 0, 0, 127);
imagefill($transparentImage, 0, 0, $transparent);
// Get the color of the corners to determine background color
// Assuming corners are background
$topLeftColor = imagecolorat($sourceImage, 0, 0);
$topRightColor = imagecolorat($sourceImage, $width - 1, 0);
$bottomLeftColor = imagecolorat($sourceImage, 0, $height - 1);
$bottomRightColor = imagecolorat($sourceImage, $width - 1, $height - 1);
// Use the most common corner color as background
$cornerColors = [$topLeftColor, $topRightColor, $bottomLeftColor, $bottomRightColor];
$backgroundColorInt = $this->getMostCommonColor($cornerColors);
// Extract RGB from the background color
$backgroundRGB = [
'r' => ($backgroundColorInt >> 16) & 0xFF,
'g' => ($backgroundColorInt >> 8) & 0xFF,
'b' => $backgroundColorInt & 0xFF,
];
// Tolerance for color matching (adjust for better results)
// Higher = more aggressive removal, Lower = more conservative
$tolerance = 30;
// Process each pixel
for ($y = 0; $y < $height; $y++) {
for ($x = 0; $x < $width; $x++) {
$pixelColor = imagecolorat($sourceImage, $x, $y);
$pixelRGB = [
'r' => ($pixelColor >> 16) & 0xFF,
'g' => ($pixelColor >> 8) & 0xFF,
'b' => $pixelColor & 0xFF,
];
// Calculate color difference
$colorDiff = abs($pixelRGB['r'] - $backgroundRGB['r'])
+ abs($pixelRGB['g'] - $backgroundRGB['g'])
+ abs($pixelRGB['b'] - $backgroundRGB['b']);
// If pixel is similar to background, make it transparent
if ($colorDiff <= $tolerance) {
imagesetpixel($transparentImage, $x, $y, $transparent);
} else {
// Keep original pixel
$newColor = imagecolorallocate(
$transparentImage,
$pixelRGB['r'],
$pixelRGB['g'],
$pixelRGB['b']
);
imagesetpixel($transparentImage, $x, $y, $newColor);
}
}
}
// Save as PNG (to preserve transparency)
$outputPath = preg_replace('/\.(jpg|jpeg)$/i', '.png', $imagePath);
imagepng($transparentImage, $outputPath, 9); // 9 = best compression
// Clean up
imagedestroy($sourceImage);
imagedestroy($transparentImage);
// Delete original if it was converted from JPG to PNG
if ($outputPath !== $imagePath && file_exists($imagePath)) {
unlink($imagePath);
}
return $outputPath;
} catch (\Exception $e) {
Log::error('Background removal failed: '.$e->getMessage());
return $imagePath; // Return original on error
}
}
/**
* Get the most common color from an array of color integers
*/
private function getMostCommonColor(array $colors): int
{
$colorCounts = array_count_values($colors);
arsort($colorCounts);
return array_key_first($colorCounts);
}
/**
* Alternative method: Remove white/light backgrounds specifically
* Better for product photos on white backgrounds
*/
public function removeWhiteBackground(string $imagePath, int $threshold = 240): ?string
{
try {
if (! extension_loaded('gd')) {
Log::warning('GD extension not loaded, skipping background removal');
return $imagePath;
}
$imageInfo = getimagesize($imagePath);
if (! $imageInfo) {
return $imagePath;
}
$mimeType = $imageInfo['mime'];
$sourceImage = match ($mimeType) {
'image/jpeg', 'image/jpg' => imagecreatefromjpeg($imagePath),
'image/png' => imagecreatefrompng($imagePath),
default => null
};
if (! $sourceImage) {
return $imagePath;
}
$width = imagesx($sourceImage);
$height = imagesy($sourceImage);
$transparentImage = imagecreatetruecolor($width, $height);
imagealphablending($transparentImage, false);
imagesavealpha($transparentImage, true);
$transparent = imagecolorallocatealpha($transparentImage, 0, 0, 0, 127);
imagefill($transparentImage, 0, 0, $transparent);
// Process each pixel
for ($y = 0; $y < $height; $y++) {
for ($x = 0; $x < $width; $x++) {
$pixelColor = imagecolorat($sourceImage, $x, $y);
$rgb = [
'r' => ($pixelColor >> 16) & 0xFF,
'g' => ($pixelColor >> 8) & 0xFF,
'b' => $pixelColor & 0xFF,
];
// If pixel is white-ish (all RGB values above threshold), make transparent
if ($rgb['r'] >= $threshold && $rgb['g'] >= $threshold && $rgb['b'] >= $threshold) {
imagesetpixel($transparentImage, $x, $y, $transparent);
} else {
$newColor = imagecolorallocate($transparentImage, $rgb['r'], $rgb['g'], $rgb['b']);
imagesetpixel($transparentImage, $x, $y, $newColor);
}
}
}
$outputPath = preg_replace('/\.(jpg|jpeg)$/i', '.png', $imagePath);
imagepng($transparentImage, $outputPath, 9);
imagedestroy($sourceImage);
imagedestroy($transparentImage);
if ($outputPath !== $imagePath && file_exists($imagePath)) {
unlink($imagePath);
}
return $outputPath;
} catch (\Exception $e) {
Log::error('White background removal failed: '.$e->getMessage());
return $imagePath;
}
}
}

View File

@@ -0,0 +1,450 @@
<?php
namespace App\Services;
use App\Models\Business;
use App\Models\PermissionAuditLog;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class PermissionService
{
/**
* Check if user has a specific permission for current business
*/
public function check(User $user, string $permission, ?Business $business = null): bool
{
try {
// Get business context
$business = $business ?? currentBusiness();
if (! $business) {
Log::warning('Permission check without business context', [
'user_id' => $user->id,
'permission' => $permission,
]);
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);
} catch (\Exception $e) {
Log::error('Permission check failed', [
'user_id' => $user->id,
'permission' => $permission,
'business_id' => $business?->id,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Check if permission exists in list (supports wildcards)
*/
protected function hasPermissionInList(string $permission, array $permissionList): bool
{
// Exact match
if (in_array($permission, $permissionList)) {
return true;
}
// Wildcard match (e.g., analytics.* matches analytics.overview)
foreach ($permissionList as $userPermission) {
if (Str::endsWith($userPermission, '.*')) {
$prefix = Str::beforeLast($userPermission, '.*');
if (Str::startsWith($permission, $prefix.'.')) {
return true;
}
}
}
return false;
}
/**
* Grant permissions to a user for a business
*/
public function grant(User $user, array $permissions, ?Business $business = null, ?string $reason = null): bool
{
$business = $business ?? currentBusiness();
if (! $business) {
return false;
}
$businessUser = $user->businesses()->where('businesses.id', $business->id)->first();
if (! $businessUser) {
return false;
}
$currentPermissions = $businessUser->pivot->permissions ?? [];
$newPermissions = array_unique(array_merge($currentPermissions, $permissions));
// Update permissions
$user->businesses()->updateExistingPivot($business->id, [
'permissions' => $newPermissions,
'permissions_updated_at' => now(),
]);
// Audit log for each permission granted
foreach ($permissions as $permission) {
if (! in_array($permission, $currentPermissions)) {
$this->logPermissionChange(
business: $business,
targetUser: $user,
action: PermissionAuditLog::ACTION_GRANTED,
permission: $permission,
permissionsBefore: $currentPermissions,
permissionsAfter: $newPermissions,
reason: $reason
);
}
}
// Clear permission cache
$this->clearPermissionCache($user->id, $business->id);
return true;
}
/**
* Revoke permissions from a user for a business
*/
public function revoke(User $user, array $permissions, ?Business $business = null, ?string $reason = null): bool
{
$business = $business ?? currentBusiness();
if (! $business) {
return false;
}
$businessUser = $user->businesses()->where('businesses.id', $business->id)->first();
if (! $businessUser) {
return false;
}
$currentPermissions = $businessUser->pivot->permissions ?? [];
$newPermissions = array_diff($currentPermissions, $permissions);
// Update permissions
$user->businesses()->updateExistingPivot($business->id, [
'permissions' => array_values($newPermissions),
'permissions_updated_at' => now(),
]);
// Audit log for each permission revoked
foreach ($permissions as $permission) {
if (in_array($permission, $currentPermissions)) {
$this->logPermissionChange(
business: $business,
targetUser: $user,
action: PermissionAuditLog::ACTION_REVOKED,
permission: $permission,
permissionsBefore: $currentPermissions,
permissionsAfter: $newPermissions,
reason: $reason
);
}
}
// Clear permission cache
$this->clearPermissionCache($user->id, $business->id);
return true;
}
/**
* Set exact permissions (replaces all existing permissions)
*/
public function setPermissions(
User $user,
array $permissions,
?Business $business = null,
?string $roleTemplate = null,
?string $reason = null
): bool {
$business = $business ?? currentBusiness();
if (! $business) {
return false;
}
$businessUser = $user->businesses()->where('businesses.id', $business->id)->first();
if (! $businessUser) {
return false;
}
$currentPermissions = $businessUser->pivot->permissions ?? [];
$currentRoleTemplate = $businessUser->pivot->role_template;
// Update permissions and role template
$user->businesses()->updateExistingPivot($business->id, [
'permissions' => array_values(array_unique($permissions)),
'role_template' => $roleTemplate,
'permissions_updated_at' => now(),
]);
// Audit log
$action = $roleTemplate && $roleTemplate !== $currentRoleTemplate
? PermissionAuditLog::ACTION_ROLE_CHANGED
: PermissionAuditLog::ACTION_BULK_UPDATE;
$this->logPermissionChange(
business: $business,
targetUser: $user,
action: $action,
permissionsBefore: $currentPermissions,
permissionsAfter: $permissions,
oldRoleTemplate: $currentRoleTemplate,
newRoleTemplate: $roleTemplate,
reason: $reason
);
// Clear permission cache
$this->clearPermissionCache($user->id, $business->id);
return true;
}
/**
* Apply a role template to a user
*
* @param bool $merge If true, merges with existing permissions. If false, replaces.
* @return array|null The permissions that were applied, or null if template not found
*/
public function applyRoleTemplate(
User $user,
string $templateKey,
?Business $business = null,
bool $merge = false
): ?array {
$business = $business ?? currentBusiness();
if (! $business) {
return null;
}
$template = config("permissions.role_templates.{$templateKey}");
if (! $template) {
Log::warning("Role template not found: {$templateKey}");
return null;
}
$templatePermissions = $template['permissions'] ?? [];
// Expand wildcards to full permission list
$expandedPermissions = $this->expandWildcards($templatePermissions);
if ($merge) {
// Merge with existing permissions
$businessUser = $user->businesses()->where('businesses.id', $business->id)->first();
$currentPermissions = $businessUser?->pivot->permissions ?? [];
$finalPermissions = array_unique(array_merge($currentPermissions, $expandedPermissions));
} else {
// Replace existing permissions
$finalPermissions = $expandedPermissions;
}
// Set permissions with role template name
$this->setPermissions(
user: $user,
permissions: $finalPermissions,
business: $business,
roleTemplate: $template['name'] ?? $templateKey,
reason: "Applied role template: {$template['name']}"
);
return $finalPermissions;
}
/**
* Expand wildcard permissions to full permission list
*/
public function expandWildcards(array $permissions): array
{
$expanded = [];
$allPermissions = $this->getAllPermissions();
foreach ($permissions as $permission) {
if (Str::endsWith($permission, '.*')) {
// Wildcard - expand to all permissions in that category
$prefix = Str::beforeLast($permission, '.*');
foreach ($allPermissions as $fullPermission) {
if (Str::startsWith($fullPermission, $prefix.'.')) {
$expanded[] = $fullPermission;
}
}
} else {
// Regular permission
$expanded[] = $permission;
}
}
return array_unique($expanded);
}
/**
* Get all available permissions from config
*/
public function getAllPermissions(): array
{
$categories = config('permissions.categories', []);
$allPermissions = [];
foreach ($categories as $categoryKey => $category) {
foreach (array_keys($category['permissions'] ?? []) as $permission) {
$allPermissions[] = $permission;
}
}
return $allPermissions;
}
/**
* Get permissions grouped by category for UI
*/
public function getPermissionsByCategory(): array
{
return config('permissions.categories', []);
}
/**
* Get available role templates
*/
public function getRoleTemplates(): array
{
return config('permissions.role_templates', []);
}
/**
* Log permission change to audit trail
*/
protected function logPermissionChange(
Business $business,
User $targetUser,
string $action,
?string $permission = null,
?array $permissionsBefore = null,
?array $permissionsAfter = null,
?string $oldRoleTemplate = null,
?string $newRoleTemplate = null,
?string $reason = null
): void {
// Determine if this is a critical permission change
$isCritical = $this->isCriticalPermission($permission) ||
$this->isCriticalAction($action);
// Calculate expiration date (null if critical)
$expiresAt = $isCritical
? null
: now()->addDays(config('permissions.audit.retention_days', 90));
PermissionAuditLog::create([
'business_id' => $business->id,
'actor_user_id' => auth()->id(),
'target_user_id' => $targetUser->id,
'action' => $action,
'permission' => $permission,
'old_role_template' => $oldRoleTemplate,
'new_role_template' => $newRoleTemplate,
'permissions_before' => $permissionsBefore,
'permissions_after' => $permissionsAfter,
'is_critical' => $isCritical,
'expires_at' => $expiresAt,
'reason' => $reason,
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
]);
}
/**
* Check if a permission is critical (should be kept forever)
*/
protected function isCriticalPermission(?string $permission): bool
{
if (! $permission) {
return false;
}
$criticalPermissions = config('permissions.audit.critical_permissions', []);
foreach ($criticalPermissions as $criticalPermission) {
if ($permission === $criticalPermission) {
return true;
}
// Check wildcard patterns
if (Str::endsWith($criticalPermission, '.*')) {
$prefix = Str::beforeLast($criticalPermission, '.*');
if (Str::startsWith($permission, $prefix.'.')) {
return true;
}
}
}
return false;
}
/**
* Check if an action is critical
*/
protected function isCriticalAction(string $action): bool
{
$criticalActions = config('permissions.audit.critical_actions', []);
return in_array($action, $criticalActions);
}
/**
* Clear permission cache for a user
*/
protected function clearPermissionCache(int $userId, int $businessId): void
{
Cache::forget("permissions.{$userId}.{$businessId}");
}
/**
* Get user's permissions with caching
*/
public function getUserPermissions(User $user, ?Business $business = null): array
{
$business = $business ?? currentBusiness();
if (! $business) {
return [];
}
return Cache::remember(
"permissions.{$user->id}.{$business->id}",
now()->addHours(1),
function () use ($user, $business) {
$businessUser = $user->businesses()
->where('businesses.id', $business->id)
->first();
return $businessUser?->pivot->permissions ?? [];
}
);
}
}

View File

@@ -0,0 +1,267 @@
<?php
namespace App\Services;
use App\Models\Batch;
use App\Models\Business;
use Illuminate\Support\Facades\Storage;
use SimpleSoftwareIO\QrCode\Facades\QrCode;
class QrCodeService
{
/**
* Generate QR code for a batch
* Links to public COA viewing page
*
* @param array $options ['size' => 300, 'with_logo' => true]
* @return array ['success' => bool, 'path' => string|null, 'url' => string|null, 'message' => string]
*/
public function generateForBatch(Batch $batch, array $options = []): array
{
$size = $options['size'] ?? 300;
$withLogo = $options['with_logo'] ?? false;
try {
// Generate the URL that QR code will link to
$coaUrl = route('public.coa.show', ['batchNumber' => $batch->batch_number]);
// Generate QR code SVG
$qrCodeSvg = QrCode::size($size)
->style('round')
->margin(1)
->generate($coaUrl);
// Determine storage path
$business = $batch->business;
$businessUuid = $business?->uuid ?? 'unknown';
$fileName = "qr-{$batch->batch_number}-".time().'.svg';
$storagePath = "businesses/{$businessUuid}/qr-codes/{$fileName}";
// Store the QR code
Storage::disk('public')->put($storagePath, $qrCodeSvg);
// Update batch with QR code path
$batch->update(['qr_code_path' => $storagePath]);
$publicUrl = Storage::url($storagePath);
return [
'success' => true,
'path' => $storagePath,
'url' => $publicUrl,
'message' => "QR code generated successfully for batch {$batch->batch_number}",
];
} catch (\Exception $e) {
return [
'success' => false,
'path' => null,
'url' => null,
'message' => 'Failed to generate QR code: '.$e->getMessage(),
];
}
}
/**
* Generate QR code with brand logo overlay
* Creates PNG format for logo support
* Priority: Brand logo > Business logo > No logo
*
* @param string|null $logoPath Path to brand logo (optional override)
* @param array $options ['size' => 300, 'logo_size' => 80]
*/
public function generateWithLogo(Batch $batch, ?string $logoPath = null, array $options = []): array
{
$size = $options['size'] ?? 300;
$logoSize = $options['logo_size'] ?? 80;
try {
// Generate the URL that QR code will link to
$coaUrl = route('public.coa.show', ['batchNumber' => $batch->batch_number]);
// Get business for storage path
$business = $batch->business;
$businessUuid = $business?->uuid ?? 'unknown';
// If no logo path provided, try to get from Brand first, then Business
if (! $logoPath) {
// Priority 1: Get brand logo from product
if ($batch->product && $batch->product->brand && $batch->product->brand->hasLogo()) {
$logoPath = Storage::disk('public')->path($batch->product->brand->logo_path);
}
// Priority 2: Fall back to business logo
elseif ($business && $business->logo_path) {
$logoPath = Storage::disk('public')->path($business->logo_path);
}
}
// Generate QR code with logo if available
$qrCode = QrCode::size($size)
->style('round')
->margin(1);
// Add logo if path exists (centered square in middle of QR code)
if ($logoPath && file_exists($logoPath)) {
// The second parameter (0.3) controls logo size relative to QR code
// The third parameter (true) creates a white background square for the logo
$qrCode->merge($logoPath, .3, true);
}
$qrCodePng = $qrCode->format('png')->generate($coaUrl);
// Determine storage path
$fileName = "qr-{$batch->batch_number}-".time().'.png';
$storagePath = "businesses/{$businessUuid}/qr-codes/{$fileName}";
// Store the QR code
Storage::disk('public')->put($storagePath, $qrCodePng);
// Update batch with QR code path
$batch->update(['qr_code_path' => $storagePath]);
$publicUrl = Storage::url($storagePath);
$logoSource = 'no logo';
if ($logoPath && file_exists($logoPath)) {
if ($batch->product && $batch->product->brand && $batch->product->brand->hasLogo()) {
$logoSource = 'brand logo';
} elseif ($business && $business->logo_path) {
$logoSource = 'business logo';
}
}
return [
'success' => true,
'path' => $storagePath,
'url' => $publicUrl,
'message' => "QR code generated successfully for batch {$batch->batch_number} ({$logoSource})",
];
} catch (\Exception $e) {
return [
'success' => false,
'path' => null,
'url' => null,
'message' => 'Failed to generate QR code with logo: '.$e->getMessage(),
];
}
}
/**
* Bulk generate QR codes for multiple batches with brand logos
*
* @return array ['successful' => int, 'failed' => int, 'results' => array]
*/
public function bulkGenerate(array $batchIds, array $options = []): array
{
$results = [];
$successful = 0;
$failed = 0;
foreach ($batchIds as $batchId) {
$batch = Batch::find($batchId);
if (! $batch) {
$failed++;
$results[] = [
'batch_id' => $batchId,
'success' => false,
'message' => 'Batch not found',
];
continue;
}
// Use generateWithLogo to include brand logos
$result = $this->generateWithLogo($batch, null, $options);
$results[] = array_merge($result, ['batch_id' => $batchId]);
if ($result['success']) {
$successful++;
} else {
$failed++;
}
}
return [
'successful' => $successful,
'failed' => $failed,
'results' => $results,
];
}
/**
* Regenerate QR code for batch with logo (delete old, create new)
*/
public function regenerate(Batch $batch, array $options = []): array
{
// Delete old QR code if exists
if ($batch->qr_code_path && Storage::disk('public')->exists($batch->qr_code_path)) {
Storage::disk('public')->delete($batch->qr_code_path);
}
// Generate new QR code with brand logo
return $this->generateWithLogo($batch, null, $options);
}
/**
* Delete QR code for batch
*/
public function delete(Batch $batch): array
{
try {
if ($batch->qr_code_path && Storage::disk('public')->exists($batch->qr_code_path)) {
Storage::disk('public')->delete($batch->qr_code_path);
$batch->update(['qr_code_path' => null]);
return [
'success' => true,
'message' => 'QR code deleted successfully',
];
}
return [
'success' => false,
'message' => 'No QR code found for this batch',
];
} catch (\Exception $e) {
return [
'success' => false,
'message' => 'Failed to delete QR code: '.$e->getMessage(),
];
}
}
/**
* Get QR code download response for batch
*
* @return \Symfony\Component\HttpFoundation\StreamedResponse|null
*/
public function download(Batch $batch)
{
if (! $batch->qr_code_path || ! Storage::disk('public')->exists($batch->qr_code_path)) {
return null;
}
$fileName = "QR-{$batch->batch_number}.svg";
return Storage::disk('public')->download($batch->qr_code_path, $fileName);
}
/**
* Generate QR code data URL (base64 encoded for inline display)
*/
public function generateDataUrl(Batch $batch, int $size = 200): ?string
{
try {
$coaUrl = route('public.coa.show', ['batchNumber' => $batch->batch_number]);
$qrCodeSvg = QrCode::size($size)
->style('round')
->margin(1)
->generate($coaUrl);
return 'data:image/svg+xml;base64,'.base64_encode($qrCodeSvg);
} catch (\Exception $e) {
return null;
}
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Services;
use App\Mail\Seller\NewOrderReceivedMail;
use App\Mail\Seller\OrderCancelledMail;
use App\Mail\Seller\PaymentReceivedMail as SellerPaymentReceivedMail;
use App\Models\Business;
use App\Models\Invoice;
use App\Models\Order;
use App\Models\User;
@@ -26,25 +27,294 @@ class SellerNotificationService
}
/**
* Notify sellers when a new order is received.
* Parse comma-separated email addresses from notification settings.
*/
public function newOrderReceived(Order $order): void
protected function parseEmailList(?string $emailList): array
{
$sellers = $this->getSellerUsers();
if (empty($emailList)) {
return [];
}
foreach ($sellers as $seller) {
// Send email
Mail::to($seller->email)->send(new NewOrderReceivedMail($order));
return array_filter(
array_map('trim', explode(',', $emailList)),
fn ($email) => ! empty($email) && filter_var($email, FILTER_VALIDATE_EMAIL)
);
}
// Create in-app notification
$this->notificationService->create(
user: $seller,
type: 'seller_new_order',
title: 'New Order Received',
message: "New order {$order->order_number} from {$order->business->name}. Total: $".number_format($order->total, 2),
actionUrl: route('seller.orders.show', $order),
notifiable: $order
);
/**
* Get the seller business from an order (the business that owns the product being sold).
*/
protected function getSellerBusinessFromOrder(Order $order): ?Business
{
// Get seller business from first order item's product's brand
$firstItem = $order->items()->with('product.brand.business')->first();
return $firstItem?->product?->brand?->business;
}
/**
* Check if buyer has any sales reps assigned.
*/
protected function buyerHasSalesRep(Business $buyer): bool
{
// TODO: Implement sales rep relationship checking when sales rep system is built
// For now, return false (no sales reps assigned)
return false;
}
/**
* Get sales reps assigned to a buyer.
*/
protected function getSalesRepsForBuyer(Business $buyer): \Illuminate\Support\Collection
{
// TODO: Implement sales rep relationship when sales rep system is built
// For now, return empty collection
return collect();
}
/**
* Get company admin users for a business.
*/
protected function getCompanyAdmins(Business $business): \Illuminate\Support\Collection
{
// Get users associated with this business who have admin role
return $business->users()
->whereHas('roles', function ($query) {
$query->where('name', User::ROLE_SUPER_ADMIN);
})
->get();
}
/**
* NEW ORDER EMAIL NOTIFICATIONS
*
* RULES:
* 1. Base: Email addresses in 'new_order_email_notifications' when new order is placed
* 2. If 'new_order_only_when_no_sales_rep' is TRUE: ONLY send if buyer has NO sales rep assigned
* 3. If 'new_order_do_not_send_to_admins' is TRUE: Do NOT send to company admins (only to custom addresses)
* 4. If 'new_order_do_not_send_to_admins' is FALSE: Send to BOTH custom addresses AND company admins
*/
public function newOrderReceived(Order $order, bool $isManualOrder = false): void
{
$sellerBusiness = $this->getSellerBusinessFromOrder($order);
if (! $sellerBusiness) {
return;
}
$buyerBusiness = $order->business;
// Check manual order notification settings
if ($isManualOrder && ! $sellerBusiness->enable_manual_order_email_notifications) {
return; // Don't send notifications for manual orders if disabled
}
// RULE 2: Check if we should only send when buyer has no sales rep
if ($sellerBusiness->new_order_only_when_no_sales_rep) {
if ($this->buyerHasSalesRep($buyerBusiness)) {
return; // Buyer has sales rep, don't send
}
}
// RULE 1: Get custom email addresses from settings
$customEmails = $this->parseEmailList($sellerBusiness->new_order_email_notifications);
// RULE 3 & 4: Determine if we should send to admins
$sendToAdmins = ! $sellerBusiness->new_order_do_not_send_to_admins;
// Collect all recipients
$recipients = [];
// Add custom email addresses
foreach ($customEmails as $email) {
$recipients[] = $email;
}
// Add company admins if enabled
if ($sendToAdmins) {
$admins = $this->getCompanyAdmins($sellerBusiness);
foreach ($admins as $admin) {
$recipients[] = $admin->email;
}
}
// Remove duplicates
$recipients = array_unique($recipients);
// Send emails
foreach ($recipients as $email) {
Mail::to($email)->send(new NewOrderReceivedMail($order));
}
// Create in-app notifications for admin users only
if ($sendToAdmins) {
$admins = $this->getCompanyAdmins($sellerBusiness);
foreach ($admins as $admin) {
$this->notificationService->create(
user: $admin,
type: 'seller_new_order',
title: 'New Order Received',
message: "New order {$order->order_number} from {$buyerBusiness->name}. Total: $".number_format($order->total, 2),
actionUrl: route('seller.business.orders.show', [$sellerBusiness->slug, $order]),
notifiable: $order
);
}
}
}
/**
* ORDER ACCEPTED EMAIL NOTIFICATIONS
*
* RULES:
* 1. Base: Email addresses in 'order_accepted_email_notifications' when order is accepted
* 2. This notification has no conditional logic - always sends if addresses are configured
* 3. Note: 'enable_shipped_emails_for_sales_reps' is for SHIPPED status, not accepted (handled separately)
*/
public function orderAccepted(Order $order): void
{
$sellerBusiness = $this->getSellerBusinessFromOrder($order);
if (! $sellerBusiness) {
return;
}
// RULE 1: Get email addresses from settings
$emails = $this->parseEmailList($sellerBusiness->order_accepted_email_notifications);
if (empty($emails)) {
return; // No emails configured
}
// Send emails to all configured addresses
foreach ($emails as $email) {
// TODO: Create OrderAcceptedMail class
// Mail::to($email)->send(new OrderAcceptedMail($order));
}
}
/**
* ORDER SHIPPED EMAIL NOTIFICATIONS (for sales reps)
*
* RULES:
* 1. If 'enable_shipped_emails_for_sales_reps' is TRUE: Send to sales reps assigned to the buyer
* 2. If FALSE: Don't send shipped notifications to sales reps
*/
public function orderShipped(Order $order): void
{
$sellerBusiness = $this->getSellerBusinessFromOrder($order);
if (! $sellerBusiness) {
return;
}
// RULE 1: Check if sales rep shipped emails are enabled
if (! $sellerBusiness->enable_shipped_emails_for_sales_reps) {
return;
}
$buyerBusiness = $order->business;
// RULE 1: Get sales reps assigned to this buyer
$salesReps = $this->getSalesRepsForBuyer($buyerBusiness);
if ($salesReps->isEmpty()) {
return;
}
// Send emails to all sales reps
foreach ($salesReps as $salesRep) {
// TODO: Create OrderShippedForSalesRepMail class
// Mail::to($salesRep->email)->send(new OrderShippedForSalesRepMail($order));
}
}
/**
* PLATFORM INQUIRY EMAIL NOTIFICATIONS
*
* RULES:
* 1. Sales reps associated with customer ALWAYS receive email
* 2. Custom addresses in 'platform_inquiry_email_notifications' ALWAYS receive email
* 3. If NO custom addresses AND NO sales reps exist: company admins receive notifications
*/
public function platformInquiry(Business $buyerBusiness, Business $sellerBusiness, string $inquiryMessage): void
{
// RULE 1: Get sales reps for this buyer
$salesReps = $this->getSalesRepsForBuyer($buyerBusiness);
// RULE 2: Get custom email addresses
$customEmails = $this->parseEmailList($sellerBusiness->platform_inquiry_email_notifications);
// Collect recipients
$recipients = [];
// Add sales reps (ALWAYS)
foreach ($salesReps as $salesRep) {
$recipients[] = $salesRep->email;
}
// Add custom emails (ALWAYS if configured)
foreach ($customEmails as $email) {
$recipients[] = $email;
}
// RULE 3: If no recipients yet, send to company admins
if (empty($recipients)) {
$admins = $this->getCompanyAdmins($sellerBusiness);
foreach ($admins as $admin) {
$recipients[] = $admin->email;
}
}
// Remove duplicates
$recipients = array_unique($recipients);
// Send emails
foreach ($recipients as $email) {
// TODO: Create PlatformInquiryMail class
// Mail::to($email)->send(new PlatformInquiryMail($buyerBusiness, $inquiryMessage));
}
}
/**
* LOW INVENTORY EMAIL NOTIFICATIONS
*
* RULES:
* 1. Base: Email addresses in 'low_inventory_email_notifications' when inventory is low
* 2. No conditional logic - straightforward notification
*/
public function lowInventory(Business $sellerBusiness, $product, int $currentQuantity, int $threshold): void
{
// RULE 1: Get email addresses from settings
$emails = $this->parseEmailList($sellerBusiness->low_inventory_email_notifications);
if (empty($emails)) {
return; // No emails configured
}
// Send emails to all configured addresses
foreach ($emails as $email) {
// TODO: Create LowInventoryMail class
// Mail::to($email)->send(new LowInventoryMail($product, $currentQuantity, $threshold));
}
}
/**
* CERTIFIED SELLER STATUS EMAIL NOTIFICATIONS
*
* RULES:
* 1. Base: Email addresses in 'certified_seller_status_email_notifications' when status changes
* 2. No conditional logic - straightforward notification
*/
public function certifiedSellerStatusChanged(Business $sellerBusiness, string $oldStatus, string $newStatus): void
{
// RULE 1: Get email addresses from settings
$emails = $this->parseEmailList($sellerBusiness->certified_seller_status_email_notifications);
if (empty($emails)) {
return; // No emails configured
}
// Send emails to all configured addresses
foreach ($emails as $email) {
// TODO: Create CertifiedSellerStatusChangedMail class
// Mail::to($email)->send(new CertifiedSellerStatusChangedMail($sellerBusiness, $oldStatus, $newStatus));
}
}

58
app/Traits/HasHashid.php Normal file
View File

@@ -0,0 +1,58 @@
<?php
namespace App\Traits;
trait HasHashid
{
/**
* Boot the trait - automatically generate hashid on creation
*/
protected static function bootHasHashid(): void
{
static::creating(function ($model) {
if (empty($model->hashid)) {
$model->hashid = $model->generateHashid();
}
});
}
/**
* Generate a unique hashid in NNLLN format
* Example: 26bf7, 83jk2, 45mn9
* Excludes: 0, o, l, i to prevent confusion
*/
public function generateHashid(): string
{
$numbers = '123456789'; // Exclude 0
$letters = 'abcdefghjkmnpqrstuvwxyz'; // Exclude i, l, o
do {
$hashid = $numbers[rand(0, strlen($numbers) - 1)]
.$numbers[rand(0, strlen($numbers) - 1)]
.$letters[rand(0, strlen($letters) - 1)]
.$letters[rand(0, strlen($letters) - 1)]
.$numbers[rand(0, strlen($numbers) - 1)];
// Check if this hashid already exists
$exists = static::where('hashid', $hashid)->exists();
} while ($exists);
return $hashid;
}
/**
* Get the route key for the model (use hashid instead of id)
*/
public function getRouteKeyName(): string
{
return 'hashid';
}
/**
* Scope query to find by hashid
*/
public function scopeByHashid($query, string $hashid)
{
return $query->where('hashid', $hashid);
}
}

View File

@@ -1,5 +1,7 @@
<?php
use App\Helpers\BusinessHelper;
if (! function_exists('dashboard_url')) {
function dashboard_url(): string
{
@@ -10,7 +12,27 @@ if (! function_exists('dashboard_url')) {
return url('/');
}
// Simple dashboard URL (LeafLink-style)
// Simple dashboard URL
return route('dashboard');
}
}
if (! function_exists('currentBusiness')) {
/**
* Get the current business for the authenticated user
*/
function currentBusiness(): ?\App\Models\Business
{
return BusinessHelper::current();
}
}
if (! function_exists('hasBusinessPermission')) {
/**
* Check if user has permission for current business
*/
function hasBusinessPermission(string $permission): bool
{
return BusinessHelper::hasPermission($permission);
}
}

View File

@@ -26,6 +26,12 @@ return Application::configure(basePath: dirname(__DIR__))
\Illuminate\Http\Request::HEADER_X_FORWARDED_AWS_ELB
);
// Add View As middleware to web group
$middleware->web(append: [
\App\Http\Middleware\ViewAsMiddleware::class,
\App\Http\Middleware\UpdateLastLogin::class,
]);
$middleware->alias([
'approved' => \App\Http\Middleware\EnsureUserApproved::class,
'buyer' => \App\Http\Middleware\EnsureUserIsBuyer::class,

View File

@@ -3,5 +3,6 @@
return [
App\Providers\AppServiceProvider::class,
App\Providers\Filament\AdminPanelProvider::class,
App\Providers\HorizonServiceProvider::class,
App\Providers\TelescopeServiceProvider::class,
];

37
check-all-sql-files.php Normal file
View File

@@ -0,0 +1,37 @@
<?php
echo "=== CHECKING ALL SQL FILES FOR BRANDS ===\n\n";
$sqlFiles = [
'hubexport.sql',
'hash-factory-data.sql',
'export-hash-factory-products.sql',
];
foreach ($sqlFiles as $file) {
$path = __DIR__.'/'.$file;
if (! file_exists($path)) {
echo "$file: NOT FOUND\n";
continue;
}
$sql = file_get_contents($path);
$fileSize = filesize($path);
echo "$file (".round($fileSize / 1024 / 1024, 2)." MB):\n";
if (preg_match_all('/INSERT INTO `brands` VALUES\s*\((.*?)\);/s', $sql, $inserts)) {
echo ' Brands: '.count($inserts[1])."\n";
} else {
echo " Brands: 0\n";
}
if (preg_match_all('/INSERT INTO `products` VALUES\s*\((.*?)\);/s', $sql, $inserts)) {
echo ' Products: '.count($inserts[1])."\n";
} else {
echo " Products: 0\n";
}
echo "\n";
}

View File

@@ -0,0 +1,187 @@
<?php
require __DIR__.'/vendor/autoload.php';
$app = require_once __DIR__.'/bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
use App\Models\Brand;
use Illuminate\Support\Facades\Storage;
echo "=== CHECKING ALL BRAND IMAGES ===\n\n";
// Connect to live database
$host = 'sql1.creationshop.net';
$username = 'claude';
$password = 'claude';
$database = 'hub_cannabrands';
try {
$conn = new PDO("mysql:host=$host;dbname=$database", $username, $password);
$conn->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
echo "✓ Connected to live database\n\n";
} catch (PDOException $e) {
exit('ERROR: '.$e->getMessage()."\n");
}
// Get all brands from live database
$stmt = $conn->query('
SELECT name, image, banner
FROM brands
ORDER BY name
');
$liveBrands = $stmt->fetchAll(PDO::FETCH_ASSOC);
$liveBrandMap = [];
foreach ($liveBrands as $liveBrand) {
$liveBrandMap[$liveBrand['name']] = $liveBrand;
}
// Get all Cannabrands brands
$brands = Brand::where('business_id', 5)->orderBy('name')->get();
echo 'Found '.count($brands)." brands in PostgreSQL\n\n";
$missingLogos = [];
$missingBanners = [];
$imported = 0;
foreach ($brands as $brand) {
$hasLogo = $brand->logo_path && Storage::disk('public')->exists($brand->logo_path);
$hasBanner = $brand->banner_path && Storage::disk('public')->exists($brand->banner_path);
$logoStatus = $hasLogo ? '✓' : '✗';
$bannerStatus = $hasBanner ? '✓' : '✗';
echo "{$brand->name} (Hashid: {$brand->hashid}):\n";
echo " Logo: $logoStatus ".($brand->logo_path ?? 'NULL')."\n";
echo " Banner: $bannerStatus ".($brand->banner_path ?? 'NULL')."\n";
// Try to find in live database (handle name variations)
$liveData = null;
if (isset($liveBrandMap[$brand->name])) {
$liveData = $liveBrandMap[$brand->name];
} elseif ($brand->name === 'Dairy2Dank' && isset($liveBrandMap['Dairy to Dank'])) {
$liveData = $liveBrandMap['Dairy to Dank'];
}
if (! $liveData) {
echo " ⚠️ Not found in live database\n\n";
continue;
}
$needsUpdate = false;
// Import missing logo
if (! $hasLogo && $liveData['image'] && strlen($liveData['image']) > 100) {
echo " → Importing logo...\n";
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->buffer($liveData['image']);
$extension = match ($mimeType) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
default => 'png'
};
$newPath = "{$brand->hashid}/logo.{$extension}";
if (! Storage::disk('public')->exists($brand->hashid)) {
Storage::disk('public')->makeDirectory($brand->hashid);
}
Storage::disk('public')->put($newPath, $liveData['image']);
$brand->logo_path = $newPath;
$needsUpdate = true;
$imported++;
echo " ✓ Logo imported: {$newPath}\n";
}
// Import missing banner
if (! $hasBanner && $liveData['banner'] && strlen($liveData['banner']) > 0 && strlen($liveData['banner']) < 200) {
echo " → Importing banner...\n";
$baseUrl = 'https://hub.cannabrands.com/storage/';
$bannerUrl = $baseUrl.str_replace(' ', '%20', $liveData['banner']);
$ch = curl_init($bannerUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
$imageData = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 200 && $imageData && strlen($imageData) > 100) {
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->buffer($imageData);
$extension = match ($mimeType) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
default => 'png'
};
$newPath = "{$brand->hashid}/banner.{$extension}";
if (! Storage::disk('public')->exists($brand->hashid)) {
Storage::disk('public')->makeDirectory($brand->hashid);
}
Storage::disk('public')->put($newPath, $imageData);
$brand->banner_path = $newPath;
$needsUpdate = true;
$imported++;
echo " ✓ Banner imported: {$newPath}\n";
} else {
echo " ✗ Failed to download banner (HTTP {$httpCode})\n";
}
}
if ($needsUpdate) {
$brand->save();
}
if (! $hasLogo && ! $liveData['image']) {
$missingLogos[] = $brand->name;
}
if (! $hasBanner && (! $liveData['banner'] || strlen($liveData['banner']) > 200)) {
$missingBanners[] = $brand->name;
}
echo "\n";
}
echo "\n=== SUMMARY ===\n";
echo 'Total brands checked: '.count($brands)."\n";
echo "Images imported: $imported\n";
if (count($missingLogos) > 0) {
echo "\nBrands missing logos (not in live database):\n";
foreach ($missingLogos as $name) {
echo " - $name\n";
}
}
if (count($missingBanners) > 0) {
echo "\nBrands missing banners (not in live database):\n";
foreach ($missingBanners as $name) {
echo " - $name\n";
}
}
echo "\n=== FINAL STATUS ===\n";
$brands = Brand::where('business_id', 5)->orderBy('name')->get();
foreach ($brands as $brand) {
$hasLogo = $brand->logo_path && Storage::disk('public')->exists($brand->logo_path);
$hasBanner = $brand->banner_path && Storage::disk('public')->exists($brand->banner_path);
$logoStatus = $hasLogo ? '✓' : '✗';
$bannerStatus = $hasBanner ? '✓' : '✗';
echo "{$logoStatus} {$bannerStatus} {$brand->name}\n";
}

27
check-brand-active.php Normal file
View File

@@ -0,0 +1,27 @@
<?php
require __DIR__.'/vendor/autoload.php';
$app = require_once __DIR__.'/bootstrap/app.php';
$app->make('Illuminate\Contracts\Console\Kernel')->bootstrap();
$brand = App\Models\Brand::where('slug', 'hash-factory')->first();
if ($brand) {
echo "Brand: {$brand->name}\n";
echo 'is_active: '.($brand->is_active ? 'YES' : 'NO')."\n";
if (! $brand->is_active) {
echo "\n⚠️ Brand is INACTIVE - this is why 404 happens!\n";
echo "Setting brand to active...\n";
$brand->is_active = true;
$brand->save();
echo "✓ Brand is now active!\n";
}
}
$product = App\Models\Product::where('hashid', '36ck3')->first();
if ($product) {
echo "\nProduct: {$product->name}\n";
echo 'is_active: '.($product->is_active ? 'YES' : 'NO')."\n";
}

15
check-brand-names.php Normal file
View File

@@ -0,0 +1,15 @@
<?php
require __DIR__.'/vendor/autoload.php';
$app = require_once __DIR__.'/bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
use App\Models\Brand;
$brands = Brand::where('business_id', 5)->orderBy('id')->get(['id', 'name']);
echo "=== BRANDS IN POSTGRESQL ===\n\n";
foreach ($brands as $b) {
echo "ID: {$b->id}, Name: '{$b->name}'\n";
}

36
check-brand.php Normal file
View File

@@ -0,0 +1,36 @@
<?php
require __DIR__.'/vendor/autoload.php';
$app = require_once __DIR__.'/bootstrap/app.php';
$app->make('Illuminate\Contracts\Console\Kernel')->bootstrap();
$brand = App\Models\Brand::where('slug', 'hash-factory')->first();
if ($brand) {
echo "✓ Brand found: {$brand->name}\n";
echo " Slug: {$brand->slug}\n";
echo " ID: {$brand->id}\n";
echo " Business ID: {$brand->business_id}\n";
} else {
echo "✗ Brand NOT found with slug 'hash-factory'\n";
$firstBrand = App\Models\Brand::first();
if ($firstBrand) {
echo "\nFirst brand in database:\n";
echo " Name: {$firstBrand->name}\n";
echo " Slug: {$firstBrand->slug}\n";
}
}
echo "\n";
$product = App\Models\Product::where('hashid', '36ck3')->first();
if ($product) {
echo "✓ Product found: {$product->name}\n";
echo " Hashid: {$product->hashid}\n";
echo " Brand: {$product->brand->name}\n";
echo " Brand Slug: {$product->brand->slug}\n";
} else {
echo "✗ Product NOT found with hashid '36ck3'\n";
}

20
check-categories.php Normal file
View File

@@ -0,0 +1,20 @@
<?php
echo 'Product Categories: '.\App\Models\ProductCategory::count().PHP_EOL;
echo 'Component Categories: '.\App\Models\ComponentCategory::count().PHP_EOL;
echo PHP_EOL.'Sample Product Categories:'.PHP_EOL;
\App\Models\ProductCategory::select('id', 'name', 'parent_id', 'business_id')
->take(10)
->get()
->each(function ($c) {
echo " ID: {$c->id}, Name: {$c->name}, Parent: {$c->parent_id}, Business: {$c->business_id}".PHP_EOL;
});
echo PHP_EOL.'Sample Component Categories:'.PHP_EOL;
\App\Models\ComponentCategory::select('id', 'name', 'parent_id', 'business_id')
->take(10)
->get()
->each(function ($c) {
echo " ID: {$c->id}, Name: {$c->name}, Parent: {$c->parent_id}, Business: {$c->business_id}".PHP_EOL;
});

13
check-columns.php Normal file
View File

@@ -0,0 +1,13 @@
<?php
echo 'Checking product_categories columns:'.PHP_EOL;
$columns = DB::select("SELECT column_name FROM information_schema.columns WHERE table_name = 'product_categories' ORDER BY ordinal_position");
foreach ($columns as $col) {
echo ' - '.$col->column_name.PHP_EOL;
}
echo PHP_EOL.'Checking component_categories columns:'.PHP_EOL;
$columns = DB::select("SELECT column_name FROM information_schema.columns WHERE table_name = 'component_categories' ORDER BY ordinal_position");
foreach ($columns as $col) {
echo ' - '.$col->column_name.PHP_EOL;
}

55
check-nuvata-images.php Normal file
View File

@@ -0,0 +1,55 @@
<?php
$sql = file_get_contents(__DIR__.'/hubexport.sql');
// Parse brands INSERT statements
if (preg_match_all('/INSERT INTO `brands` VALUES\s*\((.*?)\);/s', $sql, $inserts)) {
foreach ($inserts[1] as $brandData) {
// Parse fields
$fields = [];
$inString = false;
$current = '';
$parenDepth = 0;
for ($i = 0; $i < strlen($brandData); $i++) {
$char = $brandData[$i];
if ($char === "'" && ($i === 0 || $brandData[$i - 1] !== '\\')) {
$inString = ! $inString;
$current .= $char;
} elseif (! $inString && $char === ',' && $parenDepth === 0) {
$fields[] = trim($current);
$current = '';
} else {
if ($char === '(' && ! $inString) {
$parenDepth++;
}
if ($char === ')' && ! $inString) {
$parenDepth--;
}
$current .= $char;
}
}
if ($current !== '') {
$fields[] = trim($current);
}
// Extract brand name
$name = isset($fields[1]) ? trim($fields[1], "'") : '';
// Only process Nuvata
if ($name !== 'Nuvata') {
continue;
}
echo "Nuvata Image Data:\n\n";
// Field 4 = logo image
$imageBlob = isset($fields[4]) ? $fields[4] : 'NOT SET';
echo 'Logo (field 4): '.($imageBlob === 'NULL' ? 'NULL' : substr($imageBlob, 0, 100).'... (length: '.strlen($imageBlob).')')."\n\n";
// Field 17 = banner image
$bannerBlob = isset($fields[17]) ? $fields[17] : 'NOT SET';
echo 'Banner (field 17): '.($bannerBlob === 'NULL' ? 'NULL' : substr($bannerBlob, 0, 100).'... (length: '.strlen($bannerBlob).')')."\n\n";
}
}

64
check-nuvata-products.php Normal file
View File

@@ -0,0 +1,64 @@
<?php
$sql = file_get_contents(__DIR__.'/hubexport.sql');
echo "=== CHECKING NUVATA PRODUCTS IN OLD MYSQL ===\n\n";
// Find product INSERTs
if (preg_match_all('/INSERT INTO `products` VALUES\s*\((.*?)\);/s', $sql, $inserts)) {
echo 'Total product records: '.count($inserts[1])."\n\n";
$nuvataCount = 0;
$nuvataProducts = [];
foreach ($inserts[1] as $productData) {
// Parse fields
$fields = [];
$inString = false;
$current = '';
$parenDepth = 0;
for ($i = 0; $i < strlen($productData); $i++) {
$char = $productData[$i];
if ($char === "'" && ($i === 0 || $productData[$i - 1] !== '\\')) {
$inString = ! $inString;
$current .= $char;
} elseif (! $inString && $char === ',' && $parenDepth === 0) {
$fields[] = trim($current);
$current = '';
} else {
if ($char === '(' && ! $inString) {
$parenDepth++;
}
if ($char === ')' && ! $inString) {
$parenDepth--;
}
$current .= $char;
}
}
if ($current !== '') {
$fields[] = trim($current);
}
// Field 1 = brand_id (in old MySQL)
// Field 2 = name
$brandId = isset($fields[1]) ? $fields[1] : '';
$name = isset($fields[2]) ? trim($fields[2], "'") : '';
// Nuvata brand_id in old MySQL is 5
if ($brandId === '5') {
$nuvataCount++;
$nuvataProducts[] = $name;
}
}
echo "Nuvata products found: $nuvataCount\n\n";
if ($nuvataCount > 0) {
echo "Product names:\n";
foreach ($nuvataProducts as $product) {
echo " - $product\n";
}
}
}

56
check-nuvata-state.php Normal file
View File

@@ -0,0 +1,56 @@
<?php
$sql = file_get_contents(__DIR__.'/hubexport.sql');
// Parse brands INSERT statements
if (preg_match_all('/INSERT INTO `brands` VALUES\s*\((.*?)\);/s', $sql, $inserts)) {
foreach ($inserts[1] as $brandData) {
// Parse fields
$fields = [];
$inString = false;
$current = '';
$parenDepth = 0;
for ($i = 0; $i < strlen($brandData); $i++) {
$char = $brandData[$i];
if ($char === "'" && ($i === 0 || $brandData[$i - 1] !== '\\')) {
$inString = ! $inString;
$current .= $char;
} elseif (! $inString && $char === ',' && $parenDepth === 0) {
$fields[] = trim($current);
$current = '';
} else {
if ($char === '(' && ! $inString) {
$parenDepth++;
}
if ($char === ')' && ! $inString) {
$parenDepth--;
}
$current .= $char;
}
}
if ($current !== '') {
$fields[] = trim($current);
}
// Extract brand name
$name = isset($fields[1]) ? trim($fields[1], "'") : '';
// Only process Nuvata
if ($name !== 'Nuvata') {
continue;
}
echo "Nuvata Brand Data:\n";
echo 'State (field 14): '.(isset($fields[14]) ? $fields[14] : 'NOT SET')."\n";
echo 'State value: '.(isset($fields[14]) ? trim($fields[14], "'") : 'NULL')."\n";
echo 'State length: '.(isset($fields[14]) ? strlen(trim($fields[14], "'")) : 0)."\n";
// Show surrounding fields for context
echo "\nContext:\n";
echo 'City (field 13): '.(isset($fields[13]) ? trim($fields[13], "'") : 'NULL')."\n";
echo 'Zip (field 12): '.(isset($fields[12]) ? trim($fields[12], "'") : 'NULL')."\n";
echo 'Phone (field 15): '.(isset($fields[15]) ? trim($fields[15], "'") : 'NULL')."\n";
}
}

22
check-product-columns.php Normal file
View File

@@ -0,0 +1,22 @@
<?php
$host = 'sql1.creationshop.net';
$username = 'claude';
$password = 'claude';
$database = 'hub_cannabrands';
try {
$conn = new PDO("mysql:host=$host;dbname=$database", $username, $password);
$conn->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
echo "=== PRODUCTS TABLE COLUMNS ===\n\n";
$stmt = $conn->query('DESCRIBE products');
$columns = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($columns as $col) {
echo " {$col['Field']} ({$col['Type']})\n";
}
} catch (PDOException $e) {
echo 'ERROR: '.$e->getMessage()."\n";
}

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