Compare commits
44 Commits
fix/ci-git
...
feature/pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b112ae36d6 | ||
|
|
948ff30597 | ||
|
|
49f75f2b0e | ||
|
|
a4780a4fd8 | ||
|
|
f719686119 | ||
|
|
98518d4cfd | ||
|
|
3221134f20 | ||
|
|
3cefea3c7f | ||
|
|
64d1a0dad2 | ||
|
|
32e5e249fb | ||
|
|
c7b7a23fce | ||
|
|
3e986171a1 | ||
|
|
d2eb6e11ea | ||
|
|
0c40799ddc | ||
|
|
5215d4a077 | ||
|
|
75ec53da63 | ||
|
|
4cf62d92e4 | ||
|
|
59d81b8f42 | ||
|
|
7c0ec86823 | ||
|
|
2c3d12a22c | ||
|
|
d5ea59e83e | ||
|
|
1371d2a59c | ||
|
|
4dd2e3ae64 | ||
|
|
a133477f9f | ||
|
|
1649909b73 | ||
|
|
66d55c4f0a | ||
|
|
5dd60cc71e | ||
|
|
a5ac7d4217 | ||
|
|
eb05a6bcf0 | ||
|
|
572c207e39 | ||
|
|
ef5f430e90 | ||
|
|
a99a0807d0 | ||
|
|
a0194bad9b | ||
|
|
91451893fe | ||
|
|
1786c2edb1 | ||
|
|
9a81a22cc5 | ||
|
|
19bfa889b7 | ||
|
|
c4bd508241 | ||
|
|
b404a533b3 | ||
|
|
65380b9649 | ||
|
|
52facb768e | ||
|
|
e9230495b4 | ||
|
|
06869cf05d | ||
|
|
555b988c4f |
22
.env.example
22
.env.example
@@ -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
8
.gitignore
vendored
@@ -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
|
||||
|
||||
92
CLAUDE.md
92
CLAUDE.md
@@ -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
265
CLAUDE_CONTEXT.md
Normal 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.
|
||||
501
QUICK-HANDOFF-CLAUDE-CODE.md
Normal file
501
QUICK-HANDOFF-CLAUDE-CODE.md
Normal file
@@ -0,0 +1,501 @@
|
||||
# Analytics Implementation - Quick Handoff for Claude Code
|
||||
|
||||
## 🎯 Implementation Guide Location
|
||||
|
||||
**Main Technical Guide:** `/mnt/user-data/outputs/analytics-implementation-guide-REVISED.md`
|
||||
|
||||
This is a **REVISED** implementation that matches your ACTUAL Cannabrands architecture.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ CRITICAL ARCHITECTURAL DIFFERENCES
|
||||
|
||||
Your setup is different from typical Laravel multi-tenant apps:
|
||||
|
||||
### 1. **business_id is bigInteger (not UUID)**
|
||||
```php
|
||||
// Migration
|
||||
$table->unsignedBigInteger('business_id')->index();
|
||||
$table->foreign('business_id')->references('id')->on('businesses');
|
||||
|
||||
// NOT UUID like:
|
||||
$table->uuid('tenant_id');
|
||||
```
|
||||
|
||||
### 2. **NO Global Scopes - Explicit Scoping Pattern**
|
||||
```php
|
||||
// ❌ WRONG - Security vulnerability!
|
||||
ProductView::findOrFail($id)
|
||||
|
||||
// ✅ RIGHT - Your pattern
|
||||
ProductView::where('business_id', $business->id)->findOrFail($id)
|
||||
|
||||
// All queries MUST explicitly scope by business_id
|
||||
```
|
||||
|
||||
### 3. **Permissions in business_user.permissions JSON Column**
|
||||
```php
|
||||
// NOT using Spatie permission routes yet
|
||||
// Permissions stored in: business_user pivot table
|
||||
// Column: 'permissions' => 'array' (JSON)
|
||||
|
||||
// Check permissions via helper:
|
||||
hasBusinessPermission('analytics.overview')
|
||||
|
||||
// NOT via:
|
||||
auth()->user()->can('analytics.overview') // ❌ Don't use this yet
|
||||
```
|
||||
|
||||
### 4. **Multi-Business Users**
|
||||
```php
|
||||
// Users can belong to MULTIPLE businesses
|
||||
auth()->user()->businesses // BelongsToMany
|
||||
|
||||
// Get current business:
|
||||
auth()->user()->primaryBusiness()
|
||||
|
||||
// Or use helper:
|
||||
currentBusiness()
|
||||
currentBusinessId()
|
||||
```
|
||||
|
||||
### 5. **Products → Brand → Business Hierarchy**
|
||||
```php
|
||||
// Products DON'T have direct business_id
|
||||
// They go through Brand:
|
||||
$product->brand->business_id
|
||||
|
||||
// For tracking product views, get seller's business:
|
||||
$sellerBusiness = BusinessHelper::fromProduct($product);
|
||||
```
|
||||
|
||||
### 6. **User Types via Middleware**
|
||||
```php
|
||||
// Routes use user_type middleware:
|
||||
Route::middleware(['auth', 'verified', 'buyer']) // Buyers
|
||||
Route::middleware(['auth', 'verified', 'seller']) // Sellers
|
||||
Route::middleware(['auth', 'admin']) // Admins
|
||||
|
||||
// user_type values:
|
||||
'buyer' => 'Buyer/Retailer'
|
||||
'seller' => 'Seller/Brand'
|
||||
'admin' => 'Super Admin'
|
||||
```
|
||||
|
||||
### 7. **Reverb IS Configured (Horizon is NOT)**
|
||||
```php
|
||||
// ✅ Use Reverb for real-time updates
|
||||
use App\Events\Analytics\HighIntentBuyerDetected;
|
||||
event(new HighIntentBuyerDetected(...));
|
||||
|
||||
// ✅ Use Redis queues (already available)
|
||||
CalculateEngagementScore::dispatch()->onQueue('analytics');
|
||||
|
||||
// ❌ Don't install Horizon (not needed yet)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 WHAT YOU'RE BUILDING
|
||||
|
||||
### Database Tables (7 migrations):
|
||||
1. `analytics_events` - Raw event stream
|
||||
2. `product_views` - Product engagement tracking
|
||||
3. `email_campaigns` + `email_interactions` + `email_clicks` - Email tracking
|
||||
4. `click_tracking` - General click events
|
||||
5. `user_sessions` + `intent_signals` - Session & intent tracking
|
||||
6. `buyer_engagement_scores` - Calculated buyer scores
|
||||
7. `jobs` table for Redis queues
|
||||
|
||||
**Key Field:** Every table has `business_id` (bigInteger) with proper indexing
|
||||
|
||||
### Backend Components:
|
||||
- **Helper Functions:** `currentBusiness()`, `hasBusinessPermission()`
|
||||
- **AnalyticsTracker Service:** Main tracking service
|
||||
- **Queue Jobs:** Async engagement score calculations
|
||||
- **Events:** Reverb broadcasting for real-time updates
|
||||
- **Controllers:** Dashboard, Products, Marketing, Sales, Buyers
|
||||
- **Models:** 10 analytics models with explicit business scoping
|
||||
|
||||
### Frontend:
|
||||
- Permission management UI in existing business/users section
|
||||
- Analytics navigation (new top-level section)
|
||||
- Dashboard views with KPIs and charts
|
||||
- Real-time notifications via Reverb
|
||||
|
||||
---
|
||||
|
||||
## 🔐 SECURITY PATTERN (CRITICAL!)
|
||||
|
||||
**EVERY query MUST scope by business_id:**
|
||||
|
||||
```php
|
||||
// ❌ NEVER do this - data leakage!
|
||||
AnalyticsEvent::find($id)
|
||||
ProductView::where('product_id', $productId)->get()
|
||||
|
||||
// ✅ ALWAYS do this - business isolated
|
||||
AnalyticsEvent::where('business_id', $business->id)->find($id)
|
||||
ProductView::where('business_id', $business->id)
|
||||
->where('product_id', $productId)
|
||||
->get()
|
||||
|
||||
// ✅ Or use scope helper in models
|
||||
ProductView::forBusiness($business->id)->get()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 IMPLEMENTATION STEPS
|
||||
|
||||
### 1. Create Helper Files First
|
||||
```bash
|
||||
# Create helpers
|
||||
mkdir -p app/Helpers
|
||||
# Copy BusinessHelper.php
|
||||
# Copy helpers.php
|
||||
# Update composer.json autoload.files
|
||||
composer dump-autoload
|
||||
```
|
||||
|
||||
### 2. Run Migrations
|
||||
```bash
|
||||
# Copy all 7 migration files
|
||||
php artisan migrate
|
||||
|
||||
# Verify tables created
|
||||
php artisan tinker
|
||||
>>> DB::table('analytics_events')->count()
|
||||
>>> DB::table('product_views')->count()
|
||||
```
|
||||
|
||||
### 3. Create Models
|
||||
```bash
|
||||
mkdir -p app/Models/Analytics
|
||||
# Copy all model files (10 models)
|
||||
# Each model has explicit business scoping
|
||||
```
|
||||
|
||||
### 4. Create Services
|
||||
```bash
|
||||
mkdir -p app/Services/Analytics
|
||||
# Copy AnalyticsTracker service
|
||||
```
|
||||
|
||||
### 5. Create Jobs
|
||||
```bash
|
||||
mkdir -p app/Jobs/Analytics
|
||||
# Copy CalculateEngagementScore job
|
||||
```
|
||||
|
||||
### 6. Create Events
|
||||
```bash
|
||||
mkdir -p app/Events/Analytics
|
||||
# Copy HighIntentBuyerDetected event
|
||||
# Update routes/channels.php for broadcasting
|
||||
```
|
||||
|
||||
### 7. Create Controllers
|
||||
```bash
|
||||
mkdir -p app/Http/Controllers/Analytics
|
||||
# Copy all controller files
|
||||
```
|
||||
|
||||
### 8. Add Routes
|
||||
```bash
|
||||
# Update routes/web.php with analytics routes
|
||||
# Use existing middleware patterns (auth, verified)
|
||||
```
|
||||
|
||||
### 9. Update UI
|
||||
```bash
|
||||
# Add analytics navigation section
|
||||
# Add permission management tile to business/users
|
||||
# Create analytics dashboard views
|
||||
```
|
||||
|
||||
### 10. Configure Queues
|
||||
```bash
|
||||
# Start queue worker
|
||||
php artisan queue:work --queue=analytics
|
||||
|
||||
# (Reverb should already be running)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 TRACKING EXAMPLES
|
||||
|
||||
### Track Product View
|
||||
```php
|
||||
use App\Services\Analytics\AnalyticsTracker;
|
||||
|
||||
public function show(Product $product, Request $request)
|
||||
{
|
||||
$tracker = new AnalyticsTracker($request);
|
||||
$view = $tracker->trackProductView($product);
|
||||
|
||||
// Queue engagement score calculation if buyer
|
||||
if ($view && $view->buyer_business_id) {
|
||||
\App\Jobs\Analytics\CalculateEngagementScore::dispatch(
|
||||
$view->business_id,
|
||||
$view->buyer_business_id
|
||||
);
|
||||
}
|
||||
|
||||
return view('products.show', compact('product'));
|
||||
}
|
||||
```
|
||||
|
||||
### JavaScript Click Tracking
|
||||
```javascript
|
||||
// Add to your main JS
|
||||
document.addEventListener('click', function(e) {
|
||||
const trackable = e.target.closest('[data-track-click]');
|
||||
if (trackable) {
|
||||
fetch('/api/analytics/track-click', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
|
||||
},
|
||||
body: JSON.stringify({
|
||||
element_type: trackable.dataset.trackClick,
|
||||
element_id: trackable.dataset.trackId
|
||||
})
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### HTML Usage
|
||||
```blade
|
||||
<a href="{{ route('products.show', $product) }}"
|
||||
data-track-click="product_link"
|
||||
data-track-id="{{ $product->id }}">
|
||||
{{ $product->name }}
|
||||
</a>
|
||||
```
|
||||
|
||||
### Real-Time Notifications
|
||||
```javascript
|
||||
// In analytics dashboard
|
||||
const businessId = {{ $business->id }};
|
||||
|
||||
Echo.channel('analytics.business.' + businessId)
|
||||
.listen('.high-intent-buyer', (e) => {
|
||||
showNotification('🔥 Hot Lead!', `${e.buyer_name} showing high intent`);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 TESTING BUSINESS ISOLATION
|
||||
|
||||
```php
|
||||
// In php artisan tinker
|
||||
|
||||
// 1. Login as user
|
||||
auth()->loginUsingId(1);
|
||||
$business = currentBusiness();
|
||||
|
||||
// 2. Test helper
|
||||
echo "Business ID: " . currentBusinessId();
|
||||
|
||||
// 3. Test permission
|
||||
echo hasBusinessPermission('analytics.overview') ? "✅ HAS" : "❌ NO";
|
||||
|
||||
// 4. Test scoping - should only return current business data
|
||||
$count = App\Models\Analytics\ProductView::where('business_id', $business->id)->count();
|
||||
echo "My views: $count";
|
||||
|
||||
// 5. Test auto-set business_id
|
||||
$event = App\Models\Analytics\AnalyticsEvent::create([
|
||||
'event_type' => 'test'
|
||||
]);
|
||||
echo $event->business_id === $business->id ? "✅ PASS" : "❌ FAIL";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 PERMISSION SETUP
|
||||
|
||||
Add permissions to a user:
|
||||
|
||||
```php
|
||||
// In tinker or seeder
|
||||
$user = User::find(1);
|
||||
$business = $user->businesses->first();
|
||||
|
||||
// Grant analytics permissions
|
||||
$user->businesses()->updateExistingPivot($business->id, [
|
||||
'permissions' => [
|
||||
'analytics.overview',
|
||||
'analytics.products',
|
||||
'analytics.marketing',
|
||||
'analytics.sales',
|
||||
'analytics.buyers',
|
||||
'analytics.export'
|
||||
]
|
||||
]);
|
||||
|
||||
// Verify
|
||||
$pivot = $user->businesses()->find($business->id)->pivot;
|
||||
print_r($pivot->permissions);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚡ QUEUE CONFIGURATION
|
||||
|
||||
Make sure Redis is running and queue worker is started:
|
||||
|
||||
```bash
|
||||
# Check Redis
|
||||
redis-cli ping
|
||||
|
||||
# Start queue worker
|
||||
php artisan queue:work --queue=analytics --tries=3
|
||||
|
||||
# Or with supervisor (production):
|
||||
[program:cannabrands-analytics-queue]
|
||||
command=php /path/to/artisan queue:work --queue=analytics --tries=3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 NAVIGATION UPDATE
|
||||
|
||||
Add to your sidebar navigation:
|
||||
|
||||
```blade
|
||||
<!-- Analytics Section (New Top-Level) -->
|
||||
<div class="nav-section">
|
||||
<div class="nav-header">
|
||||
<svg>...</svg>
|
||||
Analytics
|
||||
</div>
|
||||
|
||||
@if(hasBusinessPermission('analytics.overview'))
|
||||
<a href="{{ route('analytics.dashboard') }}" class="nav-item">
|
||||
Overview
|
||||
</a>
|
||||
@endif
|
||||
|
||||
@if(hasBusinessPermission('analytics.products'))
|
||||
<a href="{{ route('analytics.products.index') }}" class="nav-item">
|
||||
Products
|
||||
</a>
|
||||
@endif
|
||||
|
||||
<!-- Marketing, Sales, Buyers... -->
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐛 COMMON ISSUES
|
||||
|
||||
### Issue: "business_id cannot be null"
|
||||
**Solution:** Make sure `currentBusinessId()` returns a value. User must be logged in and have a business.
|
||||
|
||||
### Issue: "Seeing other businesses' data"
|
||||
**Solution:** You forgot to scope by business_id! Check your query has `where('business_id', ...)`.
|
||||
|
||||
### Issue: "Permission check not working"
|
||||
**Solution:** Check the permissions array in business_user pivot table. Make sure it's a JSON array.
|
||||
|
||||
### Issue: "Product has no business_id"
|
||||
**Solution:** Products don't have direct business_id. Use `BusinessHelper::fromProduct($product)` to get seller's business.
|
||||
|
||||
---
|
||||
|
||||
## 📚 FILE STRUCTURE
|
||||
|
||||
```
|
||||
app/
|
||||
├── Events/Analytics/
|
||||
│ └── HighIntentBuyerDetected.php
|
||||
├── Helpers/
|
||||
│ ├── BusinessHelper.php
|
||||
│ └── helpers.php
|
||||
├── Http/Controllers/Analytics/
|
||||
│ ├── AnalyticsDashboardController.php
|
||||
│ ├── ProductAnalyticsController.php
|
||||
│ ├── MarketingAnalyticsController.php
|
||||
│ ├── SalesAnalyticsController.php
|
||||
│ └── BuyerIntelligenceController.php
|
||||
├── Jobs/Analytics/
|
||||
│ └── CalculateEngagementScore.php
|
||||
├── Models/Analytics/
|
||||
│ ├── AnalyticsEvent.php
|
||||
│ ├── ProductView.php
|
||||
│ ├── EmailCampaign.php
|
||||
│ ├── EmailInteraction.php
|
||||
│ ├── EmailClick.php
|
||||
│ ├── ClickTracking.php
|
||||
│ ├── UserSession.php
|
||||
│ ├── IntentSignal.php
|
||||
│ └── BuyerEngagementScore.php
|
||||
└── Services/Analytics/
|
||||
└── AnalyticsTracker.php
|
||||
|
||||
database/migrations/
|
||||
├── 2024_01_01_000001_create_analytics_events_table.php
|
||||
├── 2024_01_01_000002_create_product_views_table.php
|
||||
├── 2024_01_01_000003_create_email_tracking_tables.php
|
||||
├── 2024_01_01_000004_create_click_tracking_table.php
|
||||
├── 2024_01_01_000005_create_user_sessions_and_intent_tables.php
|
||||
├── 2024_01_01_000006_add_analytics_permissions_to_business_user.php
|
||||
└── 2024_01_01_000007_create_analytics_jobs_table.php
|
||||
|
||||
resources/views/analytics/
|
||||
├── dashboard.blade.php
|
||||
├── products/
|
||||
│ ├── index.blade.php
|
||||
│ └── show.blade.php
|
||||
├── marketing/
|
||||
├── sales/
|
||||
└── buyers/
|
||||
|
||||
routes/
|
||||
├── channels.php (add broadcasting channel)
|
||||
└── web.php (add analytics routes)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ DEFINITION OF DONE
|
||||
|
||||
- [ ] All 7 migrations run successfully
|
||||
- [ ] BusinessHelper and helpers.php created and autoloaded
|
||||
- [ ] All 10 analytics models created with business scoping
|
||||
- [ ] AnalyticsTracker service working
|
||||
- [ ] Queue jobs configured and tested
|
||||
- [ ] Reverb events broadcasting
|
||||
- [ ] All 5 controllers created
|
||||
- [ ] Routes added with permission checks
|
||||
- [ ] Navigation updated with Analytics section
|
||||
- [ ] Permission UI tile added
|
||||
- [ ] At least one dashboard view working
|
||||
- [ ] Business isolation verified (no cross-business data)
|
||||
- [ ] Permission checking works via business_user pivot
|
||||
- [ ] Queue worker running for analytics jobs
|
||||
- [ ] Test data can be created and viewed
|
||||
|
||||
---
|
||||
|
||||
## 🎉 READY TO IMPLEMENT!
|
||||
|
||||
Everything in the main guide is tailored to YOUR actual architecture:
|
||||
- ✅ business_id (bigInteger) not UUID
|
||||
- ✅ Explicit scoping, no global scopes
|
||||
- ✅ business_user.permissions JSON
|
||||
- ✅ Multi-business user support
|
||||
- ✅ Product → Brand → Business hierarchy
|
||||
- ✅ Reverb for real-time
|
||||
- ✅ Redis queues (no Horizon needed)
|
||||
|
||||
**Estimated implementation time: 5-6 hours**
|
||||
|
||||
Start with helpers and migrations, then build up from there! 🚀
|
||||
104
analyze-old-schema.php
Normal file
104
analyze-old-schema.php
Normal 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";
|
||||
}
|
||||
155
app/Console/Commands/CleanupPermissionAuditLogs.php
Normal file
155
app/Console/Commands/CleanupPermissionAuditLogs.php
Normal 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;
|
||||
}
|
||||
}
|
||||
56
app/Events/HighIntentBuyerDetected.php
Normal file
56
app/Events/HighIntentBuyerDetected.php
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
143
app/Helpers/BusinessHelper.php
Normal file
143
app/Helpers/BusinessHelper.php
Normal 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
24
app/Helpers/helpers.php
Normal 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);
|
||||
}
|
||||
}
|
||||
101
app/Http/Controllers/Analytics/AnalyticsDashboardController.php
Normal file
101
app/Http/Controllers/Analytics/AnalyticsDashboardController.php
Normal 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'
|
||||
));
|
||||
}
|
||||
}
|
||||
194
app/Http/Controllers/Analytics/BuyerIntelligenceController.php
Normal file
194
app/Http/Controllers/Analytics/BuyerIntelligenceController.php
Normal 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'
|
||||
));
|
||||
}
|
||||
}
|
||||
249
app/Http/Controllers/Analytics/MarketingAnalyticsController.php
Normal file
249
app/Http/Controllers/Analytics/MarketingAnalyticsController.php
Normal file
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Analytics;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Marketing\EmailEvent;
|
||||
use App\Models\Marketing\MarketingEngagement;
|
||||
use App\Services\Marketing\AnalyticsService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class MarketingAnalyticsController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected AnalyticsService $analyticsService
|
||||
) {}
|
||||
|
||||
public function index(Request $request, $business)
|
||||
{
|
||||
if (! hasBusinessPermission('analytics.marketing')) {
|
||||
abort(403, 'Unauthorized to view marketing analytics');
|
||||
}
|
||||
|
||||
$business = currentBusiness();
|
||||
$period = $request->input('period', '30');
|
||||
$startDate = now()->subDays((int) $period);
|
||||
$endDate = now();
|
||||
|
||||
// Get analytics using new service
|
||||
$overview = $this->analyticsService->getOverviewStats($business->id, $startDate, $endDate);
|
||||
$emailPerformance = $this->analyticsService->getEmailPerformance($business->id, $startDate, $endDate);
|
||||
|
||||
// Campaign overview metrics (compatible with old view)
|
||||
$metrics = [
|
||||
'total_campaigns' => $overview['active_automations'],
|
||||
'total_sent' => $emailPerformance['sent'],
|
||||
'total_delivered' => $emailPerformance['delivered'],
|
||||
'total_opened' => $emailPerformance['opened'],
|
||||
'total_clicked' => $emailPerformance['clicked'],
|
||||
'avg_open_rate' => $emailPerformance['open_rate'],
|
||||
'avg_click_rate' => $emailPerformance['click_rate'],
|
||||
];
|
||||
|
||||
// Campaign performance (paginated like old version)
|
||||
$campaigns = $this->getCampaignPerformance($business->id, $startDate, $endDate);
|
||||
|
||||
// Email engagement over time
|
||||
$engagementTrend = $this->getEngagementOverTime($business->id, $startDate, $endDate);
|
||||
|
||||
// Top performing campaigns
|
||||
$topCampaigns = $this->getTopCampaigns($business->id, $startDate, $endDate);
|
||||
|
||||
// Email client breakdown
|
||||
$emailClients = $this->getEmailClients($business->id, $startDate, $endDate);
|
||||
|
||||
// Device type breakdown
|
||||
$deviceTypes = $this->getDeviceTypes($business->id, $startDate, $endDate);
|
||||
|
||||
// Engagement score distribution
|
||||
$engagementScores = $this->getEngagementScores($business->id, $startDate, $endDate);
|
||||
|
||||
return view('seller.analytics.marketing', compact(
|
||||
'business',
|
||||
'period',
|
||||
'metrics',
|
||||
'campaigns',
|
||||
'engagementTrend',
|
||||
'topCampaigns',
|
||||
'emailClients',
|
||||
'deviceTypes',
|
||||
'engagementScores'
|
||||
));
|
||||
}
|
||||
|
||||
protected function getCampaignPerformance($businessId, $startDate, $endDate)
|
||||
{
|
||||
// Group by message_id to simulate campaigns
|
||||
return EmailEvent::where('business_id', $businessId)
|
||||
->whereBetween('created_at', [$startDate, $endDate])
|
||||
->select(
|
||||
'message_id',
|
||||
DB::raw('MAX(created_at) as sent_at'),
|
||||
DB::raw('SUM(CASE WHEN event_type = "sent" THEN 1 ELSE 0 END) as total_sent'),
|
||||
DB::raw('SUM(CASE WHEN event_type = "delivered" THEN 1 ELSE 0 END) as total_delivered'),
|
||||
DB::raw('SUM(CASE WHEN event_type = "opened" THEN 1 ELSE 0 END) as total_opened'),
|
||||
DB::raw('SUM(CASE WHEN event_type = "clicked" THEN 1 ELSE 0 END) as total_clicked')
|
||||
)
|
||||
->groupBy('message_id')
|
||||
->orderByDesc('sent_at')
|
||||
->paginate(20);
|
||||
}
|
||||
|
||||
protected function getEngagementOverTime($businessId, $startDate, $endDate)
|
||||
{
|
||||
return EmailEvent::where('business_id', $businessId)
|
||||
->whereBetween('created_at', [$startDate, $endDate])
|
||||
->select(
|
||||
DB::raw('DATE(created_at) as date'),
|
||||
DB::raw('SUM(CASE WHEN event_type = "sent" THEN 1 ELSE 0 END) as sent'),
|
||||
DB::raw('SUM(CASE WHEN event_type = "opened" THEN 1 ELSE 0 END) as opened'),
|
||||
DB::raw('SUM(CASE WHEN event_type = "clicked" THEN 1 ELSE 0 END) as clicked')
|
||||
)
|
||||
->groupBy('date')
|
||||
->orderBy('date')
|
||||
->get();
|
||||
}
|
||||
|
||||
protected function getTopCampaigns($businessId, $startDate, $endDate)
|
||||
{
|
||||
return EmailEvent::where('business_id', $businessId)
|
||||
->whereBetween('created_at', [$startDate, $endDate])
|
||||
->select(
|
||||
'message_id',
|
||||
DB::raw('SUM(CASE WHEN event_type = "sent" THEN 1 ELSE 0 END) as total_sent'),
|
||||
DB::raw('SUM(CASE WHEN event_type = "clicked" THEN 1 ELSE 0 END) as total_clicked')
|
||||
)
|
||||
->groupBy('message_id')
|
||||
->having('total_sent', '>', 0)
|
||||
->orderByRaw('(SUM(CASE WHEN event_type = "clicked" THEN 1 ELSE 0 END) / SUM(CASE WHEN event_type = "sent" THEN 1 ELSE 0 END)) DESC')
|
||||
->limit(10)
|
||||
->get();
|
||||
}
|
||||
|
||||
protected function getEmailClients($businessId, $startDate, $endDate)
|
||||
{
|
||||
$events = EmailEvent::where('business_id', $businessId)
|
||||
->whereBetween('created_at', [$startDate, $endDate])
|
||||
->where('event_type', 'opened')
|
||||
->whereNotNull('user_agent')
|
||||
->get();
|
||||
|
||||
$clients = [];
|
||||
foreach ($events as $event) {
|
||||
$client = $this->parseEmailClient($event->user_agent);
|
||||
if (! isset($clients[$client])) {
|
||||
$clients[$client] = 0;
|
||||
}
|
||||
$clients[$client]++;
|
||||
}
|
||||
|
||||
return collect($clients)->map(function ($count, $client) {
|
||||
return (object) ['email_client' => $client, 'count' => $count];
|
||||
})->sortByDesc('count')->values();
|
||||
}
|
||||
|
||||
protected function getDeviceTypes($businessId, $startDate, $endDate)
|
||||
{
|
||||
$events = EmailEvent::where('business_id', $businessId)
|
||||
->whereBetween('created_at', [$startDate, $endDate])
|
||||
->where('event_type', 'opened')
|
||||
->whereNotNull('user_agent')
|
||||
->get();
|
||||
|
||||
$devices = [];
|
||||
foreach ($events as $event) {
|
||||
$device = $this->parseDeviceType($event->user_agent);
|
||||
if (! isset($devices[$device])) {
|
||||
$devices[$device] = 0;
|
||||
}
|
||||
$devices[$device]++;
|
||||
}
|
||||
|
||||
return collect($devices)->map(function ($count, $device) {
|
||||
return (object) ['device_type' => $device, 'count' => $count];
|
||||
})->sortByDesc('count')->values();
|
||||
}
|
||||
|
||||
protected function getEngagementScores($businessId, $startDate, $endDate)
|
||||
{
|
||||
// Calculate engagement scores based on activity
|
||||
$engagements = MarketingEngagement::where('business_id', $businessId)
|
||||
->whereBetween('created_at', [$startDate, $endDate])
|
||||
->get();
|
||||
|
||||
$scores = ['High' => 0, 'Medium' => 0, 'Low' => 0, 'None' => 0];
|
||||
|
||||
foreach ($engagements as $engagement) {
|
||||
// Simple scoring: click = high, view = medium, etc
|
||||
$score = match ($engagement->engagement_type) {
|
||||
'purchase' => 100,
|
||||
'click' => 80,
|
||||
'view' => 50,
|
||||
default => 20,
|
||||
};
|
||||
|
||||
$range = match (true) {
|
||||
$score >= 80 => 'High',
|
||||
$score >= 50 => 'Medium',
|
||||
$score > 0 => 'Low',
|
||||
default => 'None',
|
||||
};
|
||||
|
||||
$scores[$range]++;
|
||||
}
|
||||
|
||||
return collect($scores)->map(function ($count, $range) {
|
||||
return (object) ['score_range' => $range, 'count' => $count];
|
||||
})->values();
|
||||
}
|
||||
|
||||
protected function parseEmailClient($userAgent)
|
||||
{
|
||||
if (stripos($userAgent, 'gmail') !== false) {
|
||||
return 'Gmail';
|
||||
}
|
||||
if (stripos($userAgent, 'outlook') !== false) {
|
||||
return 'Outlook';
|
||||
}
|
||||
if (stripos($userAgent, 'apple mail') !== false) {
|
||||
return 'Apple Mail';
|
||||
}
|
||||
if (stripos($userAgent, 'yahoo') !== false) {
|
||||
return 'Yahoo Mail';
|
||||
}
|
||||
if (stripos($userAgent, 'thunderbird') !== false) {
|
||||
return 'Thunderbird';
|
||||
}
|
||||
|
||||
return 'Other';
|
||||
}
|
||||
|
||||
protected function parseDeviceType($userAgent)
|
||||
{
|
||||
if (stripos($userAgent, 'mobile') !== false || stripos($userAgent, 'android') !== false) {
|
||||
return 'Mobile';
|
||||
}
|
||||
if (stripos($userAgent, 'tablet') !== false || stripos($userAgent, 'ipad') !== false) {
|
||||
return 'Tablet';
|
||||
}
|
||||
if (stripos($userAgent, 'windows') !== false || stripos($userAgent, 'mac') !== false) {
|
||||
return 'Desktop';
|
||||
}
|
||||
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
public function campaign(Request $request, $business, $campaign)
|
||||
{
|
||||
if (! hasBusinessPermission('analytics.marketing')) {
|
||||
abort(403, 'Unauthorized to view marketing analytics');
|
||||
}
|
||||
|
||||
$business = currentBusiness();
|
||||
|
||||
// TODO: Implement campaign-specific analytics when campaigns feature is added
|
||||
// For now, redirect to main marketing analytics
|
||||
return redirect()->route('seller.business.analytics.marketing', ['business' => $business->slug]);
|
||||
}
|
||||
}
|
||||
164
app/Http/Controllers/Analytics/ProductAnalyticsController.php
Normal file
164
app/Http/Controllers/Analytics/ProductAnalyticsController.php
Normal 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'
|
||||
));
|
||||
}
|
||||
}
|
||||
160
app/Http/Controllers/Analytics/SalesAnalyticsController.php
Normal file
160
app/Http/Controllers/Analytics/SalesAnalyticsController.php
Normal 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'
|
||||
));
|
||||
}
|
||||
}
|
||||
190
app/Http/Controllers/Analytics/TrackingController.php
Normal file
190
app/Http/Controllers/Analytics/TrackingController.php
Normal 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', [])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
185
app/Http/Controllers/Business/UserPermissionsController.php
Normal file
185
app/Http/Controllers/Business/UserPermissionsController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
63
app/Http/Controllers/Buyer/BrandBrowseController.php
Normal file
63
app/Http/Controllers/Buyer/BrandBrowseController.php
Normal 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'
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
262
app/Http/Controllers/Seller/BrandController.php
Normal file
262
app/Http/Controllers/Seller/BrandController.php
Normal 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!');
|
||||
}
|
||||
}
|
||||
60
app/Http/Controllers/Seller/BrandPreviewController.php
Normal file
60
app/Http/Controllers/Seller/BrandPreviewController.php
Normal 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'
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
224
app/Http/Controllers/Seller/CategoryController.php
Normal file
224
app/Http/Controllers/Seller/CategoryController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
27
app/Http/Controllers/Seller/DashboardV2Controller.php
Normal file
27
app/Http/Controllers/Seller/DashboardV2Controller.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
|
||||
class DashboardV2Controller extends Controller
|
||||
{
|
||||
public function index(Business $business)
|
||||
{
|
||||
// Get current view from session (sales, manufacturing, compliance)
|
||||
$currentView = session('current_view', 'sales');
|
||||
|
||||
// Load appropriate dashboard based on view
|
||||
$viewFile = match ($currentView) {
|
||||
'manufacturing' => 'seller.dashboard-v2.manufacturing',
|
||||
'compliance' => 'seller.dashboard-v2.compliance',
|
||||
default => 'seller.dashboard-v2.sales',
|
||||
};
|
||||
|
||||
return view($viewFile, [
|
||||
'business' => $business,
|
||||
'currentView' => $currentView,
|
||||
]);
|
||||
}
|
||||
}
|
||||
454
app/Http/Controllers/Seller/Marketing/BroadcastController.php
Normal file
454
app/Http/Controllers/Seller/Marketing/BroadcastController.php
Normal file
@@ -0,0 +1,454 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Marketing;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Broadcast;
|
||||
use App\Models\MarketingAudience;
|
||||
use App\Models\MarketingTemplate;
|
||||
use App\Services\Marketing\BroadcastService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class BroadcastController extends Controller
|
||||
{
|
||||
protected BroadcastService $broadcastService;
|
||||
|
||||
public function __construct(BroadcastService $broadcastService)
|
||||
{
|
||||
$this->broadcastService = $broadcastService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display list of broadcasts
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$business = $request->user()->currentBusiness;
|
||||
|
||||
$query = Broadcast::where('business_id', $business->id)
|
||||
->with('createdBy', 'template');
|
||||
|
||||
// Filter by status
|
||||
if ($request->has('status') && $request->status) {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
// Filter by channel
|
||||
if ($request->has('channel') && $request->channel) {
|
||||
$query->where('channel', $request->channel);
|
||||
}
|
||||
|
||||
// Search
|
||||
if ($request->has('search') && $request->search) {
|
||||
$query->where(function ($q) use ($request) {
|
||||
$q->where('name', 'LIKE', "%{$request->search}%")
|
||||
->orWhere('description', 'LIKE', "%{$request->search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$broadcasts = $query->orderBy('created_at', 'desc')->paginate(20);
|
||||
|
||||
return view('seller.marketing.broadcasts.index', compact('broadcasts'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show create form
|
||||
*/
|
||||
public function create(Request $request)
|
||||
{
|
||||
$business = $request->user()->currentBusiness;
|
||||
|
||||
$audiences = MarketingAudience::where('business_id', $business->id)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$templates = MarketingTemplate::where('business_id', $business->id)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('seller.marketing.broadcasts.create', compact('audiences', 'templates'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store new broadcast
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$business = $request->user()->currentBusiness;
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string|max:1000',
|
||||
'type' => 'required|in:immediate,scheduled',
|
||||
'channel' => 'required|in:email,sms,push,multi',
|
||||
'template_id' => 'nullable|exists:marketing_templates,id',
|
||||
'subject' => 'required_if:channel,email|nullable|string|max:255',
|
||||
'content' => 'required_without:template_id|nullable|string',
|
||||
'audience_ids' => 'nullable|array',
|
||||
'audience_ids.*' => 'exists:marketing_audiences,id',
|
||||
'include_all' => 'boolean',
|
||||
'exclude_audience_ids' => 'nullable|array',
|
||||
'scheduled_at' => 'required_if:type,scheduled|nullable|date|after:now',
|
||||
'timezone' => 'nullable|string',
|
||||
'track_opens' => 'boolean',
|
||||
'track_clicks' => 'boolean',
|
||||
'send_rate_limit' => 'nullable|integer|min:1|max:1000',
|
||||
]);
|
||||
|
||||
$broadcast = Broadcast::create([
|
||||
'business_id' => $business->id,
|
||||
'created_by_user_id' => $request->user()->id,
|
||||
...$validated,
|
||||
'status' => 'draft',
|
||||
]);
|
||||
|
||||
// Prepare recipients
|
||||
try {
|
||||
$count = $this->broadcastService->prepareBroadcast($broadcast);
|
||||
|
||||
return redirect()
|
||||
->route('seller.marketing.broadcasts.show', $broadcast)
|
||||
->with('success', "Broadcast created with {$count} recipients");
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return back()
|
||||
->withInput()
|
||||
->withErrors(['error' => 'Failed to prepare broadcast: '.$e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show specific broadcast
|
||||
*/
|
||||
public function show(Request $request, Broadcast $broadcast)
|
||||
{
|
||||
$business = $request->user()->currentBusiness;
|
||||
|
||||
if ($broadcast->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$broadcast->load(['createdBy', 'template', 'recipients' => function ($query) {
|
||||
$query->with('user')->latest()->limit(50);
|
||||
}]);
|
||||
|
||||
$stats = $this->broadcastService->getStatistics($broadcast);
|
||||
|
||||
// Get event timeline (recent events)
|
||||
$recentEvents = $broadcast->events()
|
||||
->with('user')
|
||||
->latest('occurred_at')
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
return view('seller.marketing.broadcasts.show', compact('broadcast', 'stats', 'recentEvents'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show edit form
|
||||
*/
|
||||
public function edit(Request $request, Broadcast $broadcast)
|
||||
{
|
||||
$business = $request->user()->currentBusiness;
|
||||
|
||||
if ($broadcast->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $broadcast->isDraft()) {
|
||||
return back()->with('error', 'Only draft broadcasts can be edited');
|
||||
}
|
||||
|
||||
$audiences = MarketingAudience::where('business_id', $business->id)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$templates = MarketingTemplate::where('business_id', $business->id)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('seller.marketing.broadcasts.edit', compact('broadcast', 'audiences', 'templates'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update broadcast
|
||||
*/
|
||||
public function update(Request $request, Broadcast $broadcast)
|
||||
{
|
||||
$business = $request->user()->currentBusiness;
|
||||
|
||||
if ($broadcast->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $broadcast->isDraft()) {
|
||||
return back()->with('error', 'Only draft broadcasts can be updated');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string|max:1000',
|
||||
'type' => 'required|in:immediate,scheduled',
|
||||
'channel' => 'required|in:email,sms,push,multi',
|
||||
'template_id' => 'nullable|exists:marketing_templates,id',
|
||||
'subject' => 'required_if:channel,email|nullable|string|max:255',
|
||||
'content' => 'required_without:template_id|nullable|string',
|
||||
'audience_ids' => 'nullable|array',
|
||||
'include_all' => 'boolean',
|
||||
'exclude_audience_ids' => 'nullable|array',
|
||||
'scheduled_at' => 'required_if:type,scheduled|nullable|date|after:now',
|
||||
'track_opens' => 'boolean',
|
||||
'track_clicks' => 'boolean',
|
||||
'send_rate_limit' => 'nullable|integer|min:1|max:1000',
|
||||
]);
|
||||
|
||||
$broadcast->update($validated);
|
||||
|
||||
// Re-prepare recipients
|
||||
try {
|
||||
$count = $this->broadcastService->prepareBroadcast($broadcast);
|
||||
|
||||
return redirect()
|
||||
->route('seller.marketing.broadcasts.show', $broadcast)
|
||||
->with('success', "Broadcast updated with {$count} recipients");
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return back()->with('error', 'Failed to update broadcast: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete broadcast
|
||||
*/
|
||||
public function destroy(Request $request, Broadcast $broadcast)
|
||||
{
|
||||
$business = $request->user()->currentBusiness;
|
||||
|
||||
if ($broadcast->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! in_array($broadcast->status, ['draft', 'cancelled', 'failed'])) {
|
||||
return back()->with('error', 'Cannot delete broadcast in current status');
|
||||
}
|
||||
|
||||
$broadcast->delete();
|
||||
|
||||
return redirect()
|
||||
->route('seller.marketing.broadcasts.index')
|
||||
->with('success', 'Broadcast deleted');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send broadcast
|
||||
*/
|
||||
public function send(Request $request, Broadcast $broadcast)
|
||||
{
|
||||
$business = $request->user()->currentBusiness;
|
||||
|
||||
if ($broadcast->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->broadcastService->sendBroadcast($broadcast);
|
||||
|
||||
return back()->with('success', 'Broadcast is now being sent');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause broadcast
|
||||
*/
|
||||
public function pause(Request $request, Broadcast $broadcast)
|
||||
{
|
||||
$business = $request->user()->currentBusiness;
|
||||
|
||||
if ($broadcast->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->broadcastService->pauseBroadcast($broadcast);
|
||||
|
||||
return back()->with('success', 'Broadcast paused');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume broadcast
|
||||
*/
|
||||
public function resume(Request $request, Broadcast $broadcast)
|
||||
{
|
||||
$business = $request->user()->currentBusiness;
|
||||
|
||||
if ($broadcast->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->broadcastService->resumeBroadcast($broadcast);
|
||||
|
||||
return back()->with('success', 'Broadcast resumed');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel broadcast
|
||||
*/
|
||||
public function cancel(Request $request, Broadcast $broadcast)
|
||||
{
|
||||
$business = $request->user()->currentBusiness;
|
||||
|
||||
if ($broadcast->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->broadcastService->cancelBroadcast($broadcast);
|
||||
|
||||
return back()->with('success', 'Broadcast cancelled');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate broadcast
|
||||
*/
|
||||
public function duplicate(Request $request, Broadcast $broadcast)
|
||||
{
|
||||
$business = $request->user()->currentBusiness;
|
||||
|
||||
if ($broadcast->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$newBroadcast = $broadcast->replicate();
|
||||
$newBroadcast->name = $broadcast->name.' (Copy)';
|
||||
$newBroadcast->status = 'draft';
|
||||
$newBroadcast->created_by_user_id = $request->user()->id;
|
||||
$newBroadcast->total_recipients = 0;
|
||||
$newBroadcast->total_sent = 0;
|
||||
$newBroadcast->total_delivered = 0;
|
||||
$newBroadcast->total_failed = 0;
|
||||
$newBroadcast->total_opened = 0;
|
||||
$newBroadcast->total_clicked = 0;
|
||||
$newBroadcast->started_sending_at = null;
|
||||
$newBroadcast->finished_sending_at = null;
|
||||
$newBroadcast->save();
|
||||
|
||||
// Prepare recipients
|
||||
$this->broadcastService->prepareBroadcast($newBroadcast);
|
||||
|
||||
return redirect()
|
||||
->route('seller.marketing.broadcasts.show', $newBroadcast)
|
||||
->with('success', 'Broadcast duplicated');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get progress (AJAX)
|
||||
*/
|
||||
public function progress(Request $request, Broadcast $broadcast)
|
||||
{
|
||||
$business = $request->user()->currentBusiness;
|
||||
|
||||
if ($broadcast->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$stats = $this->broadcastService->getStatistics($broadcast);
|
||||
|
||||
return response()->json([
|
||||
'status' => $broadcast->status,
|
||||
'stats' => $stats,
|
||||
'progress' => $broadcast->total_recipients > 0
|
||||
? round(($broadcast->total_sent / $broadcast->total_recipients) * 100, 2)
|
||||
: 0,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* View recipients
|
||||
*/
|
||||
public function recipients(Request $request, Broadcast $broadcast)
|
||||
{
|
||||
$business = $request->user()->currentBusiness;
|
||||
|
||||
if ($broadcast->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$recipients = $broadcast->recipients()
|
||||
->with('user')
|
||||
->when($request->has('status'), function ($query) use ($request) {
|
||||
$query->where('status', $request->status);
|
||||
})
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate(50);
|
||||
|
||||
return view('seller.marketing.broadcasts.recipients', compact('broadcast', 'recipients'));
|
||||
}
|
||||
|
||||
/**
|
||||
* View analytics
|
||||
*/
|
||||
public function analytics(Request $request, Broadcast $broadcast)
|
||||
{
|
||||
$business = $request->user()->currentBusiness;
|
||||
|
||||
if ($broadcast->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$stats = $this->broadcastService->getStatistics($broadcast);
|
||||
|
||||
// Get hourly breakdown
|
||||
$hourlyData = DB::table('broadcast_recipients')
|
||||
->where('broadcast_id', $broadcast->id)
|
||||
->whereNotNull('sent_at')
|
||||
->select(
|
||||
DB::raw('DATE_FORMAT(sent_at, "%Y-%m-%d %H:00:00") as hour'),
|
||||
DB::raw('COUNT(*) as count')
|
||||
)
|
||||
->groupBy('hour')
|
||||
->orderBy('hour')
|
||||
->get();
|
||||
|
||||
// Get event breakdown by type
|
||||
$eventBreakdown = DB::table('broadcast_events')
|
||||
->where('broadcast_id', $broadcast->id)
|
||||
->select('event', DB::raw('COUNT(*) as count'))
|
||||
->groupBy('event')
|
||||
->pluck('count', 'event');
|
||||
|
||||
// Top clicked links
|
||||
$topLinks = DB::table('broadcast_events')
|
||||
->where('broadcast_id', $broadcast->id)
|
||||
->where('event', 'clicked')
|
||||
->select('link_url', DB::raw('COUNT(*) as count'))
|
||||
->groupBy('link_url')
|
||||
->orderByDesc('count')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
return view('seller.marketing.broadcasts.analytics', compact(
|
||||
'broadcast',
|
||||
'stats',
|
||||
'hourlyData',
|
||||
'eventBreakdown',
|
||||
'topLinks'
|
||||
));
|
||||
}
|
||||
}
|
||||
506
app/Http/Controllers/Seller/Marketing/TemplateController.php
Normal file
506
app/Http/Controllers/Seller/Marketing/TemplateController.php
Normal file
@@ -0,0 +1,506 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Marketing;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Brand;
|
||||
use App\Models\Business;
|
||||
use App\Models\Marketing\Template;
|
||||
use App\Models\Marketing\TemplateCategory;
|
||||
use App\Services\Marketing\AIContentService;
|
||||
use App\Services\Marketing\MergeTagService;
|
||||
use App\Services\Marketing\TemplateService;
|
||||
use App\Services\PermissionService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class TemplateController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected TemplateService $templateService,
|
||||
protected AIContentService $aiContentService,
|
||||
protected MergeTagService $mergeTagService,
|
||||
protected PermissionService $permissionService
|
||||
) {}
|
||||
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$this->authorize('viewAny', [Template::class, $business]);
|
||||
|
||||
$query = Template::forBusiness($business->id)
|
||||
->with(['category', 'brands', 'analytics']);
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$query->where(function ($q) use ($request) {
|
||||
$q->where('name', 'ilike', '%'.$request->search.'%')
|
||||
->orWhere('description', 'ilike', '%'.$request->search.'%');
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->filled('category')) {
|
||||
$query->where('category_id', $request->category);
|
||||
}
|
||||
|
||||
if ($request->filled('type')) {
|
||||
$query->byType($request->type);
|
||||
}
|
||||
|
||||
if ($request->filled('brand')) {
|
||||
$query->whereHas('brands', fn ($q) => $q->where('brands.id', $request->brand));
|
||||
}
|
||||
|
||||
$sort = $request->get('sort', 'recent');
|
||||
match ($sort) {
|
||||
'popular' => $query->popular(),
|
||||
'name' => $query->orderBy('name'),
|
||||
default => $query->latest(),
|
||||
};
|
||||
|
||||
$templates = $query->paginate(24);
|
||||
|
||||
$categories = TemplateCategory::sorted()->get();
|
||||
$brands = Brand::where('business_id', $business->id)->get();
|
||||
|
||||
return view('seller.marketing.templates.index', compact(
|
||||
'business',
|
||||
'templates',
|
||||
'categories',
|
||||
'brands'
|
||||
));
|
||||
}
|
||||
|
||||
public function create(Request $request, Business $business)
|
||||
{
|
||||
$this->authorize('create', [Template::class, $business]);
|
||||
|
||||
$categories = TemplateCategory::sorted()->get();
|
||||
$brands = Brand::where('business_id', $business->id)->get();
|
||||
$mergeTags = $this->mergeTagService->getAvailableTags();
|
||||
|
||||
$templateType = $request->get('type', 'email');
|
||||
|
||||
return view('seller.marketing.templates.create', compact(
|
||||
'business',
|
||||
'categories',
|
||||
'brands',
|
||||
'mergeTags',
|
||||
'templateType'
|
||||
));
|
||||
}
|
||||
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$this->authorize('create', [Template::class, $business]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'category_id' => 'required|exists:template_categories,id',
|
||||
'template_type' => 'required|in:email,sms,push',
|
||||
'design_json' => 'nullable|json',
|
||||
'mjml_content' => 'nullable|string',
|
||||
'html_content' => 'nullable|string',
|
||||
'plain_text' => 'nullable|string',
|
||||
'tags' => 'nullable|array',
|
||||
'tags.*' => 'string|max:50',
|
||||
'brands' => 'nullable|array',
|
||||
'brands.*' => 'exists:brands,id',
|
||||
]);
|
||||
|
||||
$template = $this->templateService->create($validated);
|
||||
|
||||
if (! empty($validated['brands'])) {
|
||||
foreach ($validated['brands'] as $brandId) {
|
||||
$this->templateService->addToBrand($template, $brandId);
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.marketing.templates.show', ['business' => $business, 'template' => $template])
|
||||
->with('success', 'Template created successfully');
|
||||
}
|
||||
|
||||
public function show(Business $business, Template $template)
|
||||
{
|
||||
$this->authorize('view', [$template, $business]);
|
||||
|
||||
$template->load(['category', 'brands', 'analytics', 'creator', 'updater', 'versions']);
|
||||
|
||||
return view('seller.marketing.templates.show', compact('business', 'template'));
|
||||
}
|
||||
|
||||
public function edit(Business $business, Template $template)
|
||||
{
|
||||
$this->authorize('update', [$template, $business]);
|
||||
|
||||
if (! $template->is_editable) {
|
||||
return redirect()
|
||||
->route('seller.marketing.templates.show', ['business' => $business, 'template' => $template])
|
||||
->with('error', 'This template cannot be edited');
|
||||
}
|
||||
|
||||
$categories = TemplateCategory::sorted()->get();
|
||||
$brands = Brand::where('business_id', $business->id)->get();
|
||||
$mergeTags = $this->mergeTagService->getAvailableTags();
|
||||
|
||||
return view('seller.marketing.templates.edit', compact(
|
||||
'business',
|
||||
'template',
|
||||
'categories',
|
||||
'brands',
|
||||
'mergeTags'
|
||||
));
|
||||
}
|
||||
|
||||
public function update(Request $request, Business $business, Template $template)
|
||||
{
|
||||
$this->authorize('update', [$template, $business]);
|
||||
|
||||
if (! $template->is_editable) {
|
||||
return back()->with('error', 'This template cannot be edited');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'category_id' => 'required|exists:template_categories,id',
|
||||
'design_json' => 'nullable|json',
|
||||
'mjml_content' => 'nullable|string',
|
||||
'html_content' => 'nullable|string',
|
||||
'plain_text' => 'nullable|string',
|
||||
'tags' => 'nullable|array',
|
||||
'tags.*' => 'string|max:50',
|
||||
'change_notes' => 'nullable|string',
|
||||
'brands' => 'nullable|array',
|
||||
'brands.*' => 'exists:brands,id',
|
||||
]);
|
||||
|
||||
$this->templateService->update($template, $validated);
|
||||
|
||||
if (isset($validated['brands'])) {
|
||||
$template->brands()->sync($validated['brands']);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.marketing.templates.show', ['business' => $business, 'template' => $template])
|
||||
->with('success', 'Template updated successfully');
|
||||
}
|
||||
|
||||
public function destroy(Business $business, Template $template)
|
||||
{
|
||||
$this->authorize('delete', [$template, $business]);
|
||||
|
||||
if (! $template->canBeDeleted()) {
|
||||
return back()->with('error', 'This template cannot be deleted because it is in use');
|
||||
}
|
||||
|
||||
$this->templateService->delete($template);
|
||||
|
||||
return redirect()
|
||||
->route('seller.marketing.templates.index', ['business' => $business])
|
||||
->with('success', 'Template deleted successfully');
|
||||
}
|
||||
|
||||
public function duplicate(Request $request, Business $business, Template $template)
|
||||
{
|
||||
$this->authorize('create', [Template::class, $business]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'brand_id' => 'nullable|exists:brands,id',
|
||||
]);
|
||||
|
||||
$duplicate = $this->templateService->duplicate(
|
||||
$template,
|
||||
$validated['name'],
|
||||
$validated['brand_id'] ?? null
|
||||
);
|
||||
|
||||
return redirect()
|
||||
->route('seller.marketing.templates.edit', ['business' => $business, 'template' => $duplicate])
|
||||
->with('success', 'Template duplicated successfully');
|
||||
}
|
||||
|
||||
public function preview(Request $request, Business $business, Template $template)
|
||||
{
|
||||
$this->authorize('view', [$template, $business]);
|
||||
|
||||
$brand = null;
|
||||
if ($request->filled('brand_id')) {
|
||||
$brand = Brand::where('business_id', $business->id)
|
||||
->findOrFail($request->brand_id);
|
||||
}
|
||||
|
||||
$sampleData = [
|
||||
'buyer' => (object) [
|
||||
'name' => 'John Doe',
|
||||
'first_name' => 'John',
|
||||
'last_name' => 'Doe',
|
||||
'email' => 'john@example.com',
|
||||
'phone' => '555-0123',
|
||||
],
|
||||
'order' => (object) [
|
||||
'order_number' => 'ORD-12345',
|
||||
'total' => '$299.99',
|
||||
'created_at' => now(),
|
||||
],
|
||||
'unsubscribe_link' => '#unsubscribe',
|
||||
'view_in_browser_link' => '#view-browser',
|
||||
];
|
||||
|
||||
$rendered = $this->templateService->render($template, $sampleData, $brand);
|
||||
|
||||
return response()->json(['html' => $rendered]);
|
||||
}
|
||||
|
||||
public function sendTest(Request $request, Business $business, Template $template)
|
||||
{
|
||||
$this->authorize('view', [$template, $business]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'email' => 'required|email',
|
||||
'brand_id' => 'nullable|exists:brands,id',
|
||||
]);
|
||||
|
||||
$brand = null;
|
||||
if (isset($validated['brand_id'])) {
|
||||
$brand = Brand::where('business_id', $business->id)
|
||||
->findOrFail($validated['brand_id']);
|
||||
}
|
||||
|
||||
$sampleData = [
|
||||
'buyer' => (object) [
|
||||
'name' => auth()->user()->name,
|
||||
'email' => auth()->user()->email,
|
||||
],
|
||||
];
|
||||
|
||||
$rendered = $this->templateService->render($template, $sampleData, $brand);
|
||||
|
||||
// TODO: Integrate with mail system
|
||||
// Mail::send([], [], function ($message) use ($validated, $rendered, $template) {
|
||||
// $message->to($validated['email'])
|
||||
// ->subject('[TEST] ' . $template->name)
|
||||
// ->html($rendered);
|
||||
// });
|
||||
|
||||
return back()->with('success', 'Test email sent to '.$validated['email']);
|
||||
}
|
||||
|
||||
public function analytics(Business $business, Template $template)
|
||||
{
|
||||
$this->authorize('view', [$template, $business]);
|
||||
|
||||
$analytics = $template->analytics()
|
||||
->with('brand')
|
||||
->get();
|
||||
|
||||
$totalAnalytics = [
|
||||
'total_sends' => $analytics->sum('total_sends'),
|
||||
'total_opens' => $analytics->sum('total_opens'),
|
||||
'total_clicks' => $analytics->sum('total_clicks'),
|
||||
'total_bounces' => $analytics->sum('total_bounces'),
|
||||
'avg_open_rate' => $analytics->avg('avg_open_rate'),
|
||||
'avg_click_rate' => $analytics->avg('avg_click_rate'),
|
||||
];
|
||||
|
||||
return view('seller.marketing.templates.analytics', compact(
|
||||
'business',
|
||||
'template',
|
||||
'analytics',
|
||||
'totalAnalytics'
|
||||
));
|
||||
}
|
||||
|
||||
public function versions(Business $business, Template $template)
|
||||
{
|
||||
$this->authorize('view', [$template, $business]);
|
||||
|
||||
$versions = $template->versions()
|
||||
->with('creator')
|
||||
->latest('version_number')
|
||||
->get();
|
||||
|
||||
return view('seller.marketing.templates.versions', compact(
|
||||
'business',
|
||||
'template',
|
||||
'versions'
|
||||
));
|
||||
}
|
||||
|
||||
public function restoreVersion(Request $request, Business $business, Template $template)
|
||||
{
|
||||
$this->authorize('update', [$template, $business]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'version_id' => 'required|exists:template_versions,id',
|
||||
]);
|
||||
|
||||
$version = $template->versions()->findOrFail($validated['version_id']);
|
||||
$template->restoreVersion($version);
|
||||
|
||||
return redirect()
|
||||
->route('seller.marketing.templates.edit', ['business' => $business, 'template' => $template])
|
||||
->with('success', 'Template restored to version '.$version->version_number);
|
||||
}
|
||||
|
||||
public function addToBrand(Request $request, Business $business, Template $template)
|
||||
{
|
||||
$this->authorize('view', [$template, $business]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'brand_id' => 'required|exists:brands,id',
|
||||
]);
|
||||
|
||||
$brand = Brand::where('business_id', $business->id)
|
||||
->findOrFail($validated['brand_id']);
|
||||
|
||||
$this->templateService->addToBrand($template, $brand->id);
|
||||
|
||||
return back()->with('success', 'Template added to brand');
|
||||
}
|
||||
|
||||
public function removeFromBrand(Request $request, Business $business, Template $template)
|
||||
{
|
||||
$this->authorize('view', [$template, $business]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'brand_id' => 'required|exists:brands,id',
|
||||
]);
|
||||
|
||||
$this->templateService->removeFromBrand($template, $validated['brand_id']);
|
||||
|
||||
return back()->with('success', 'Template removed from brand');
|
||||
}
|
||||
|
||||
public function toggleFavorite(Request $request, Business $business, Template $template)
|
||||
{
|
||||
$this->authorize('view', [$template, $business]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'brand_id' => 'required|exists:brands,id',
|
||||
]);
|
||||
|
||||
$this->templateService->toggleFavorite($template, $validated['brand_id']);
|
||||
|
||||
return back()->with('success', 'Favorite status updated');
|
||||
}
|
||||
|
||||
public function export(Business $business, Template $template, string $format)
|
||||
{
|
||||
$this->authorize('view', [$template, $business]);
|
||||
|
||||
return match ($format) {
|
||||
'html' => response($this->templateService->exportToHtml($template))
|
||||
->header('Content-Type', 'text/html')
|
||||
->header('Content-Disposition', 'attachment; filename="'.$template->slug.'.html"'),
|
||||
|
||||
'mjml' => response($this->templateService->exportToMjml($template))
|
||||
->header('Content-Type', 'text/plain')
|
||||
->header('Content-Disposition', 'attachment; filename="'.$template->slug.'.mjml"'),
|
||||
|
||||
'zip' => response()
|
||||
->download($this->templateService->exportAsZip($template), $template->slug.'.zip')
|
||||
->deleteFileAfterSend(),
|
||||
|
||||
default => abort(400, 'Invalid export format'),
|
||||
};
|
||||
}
|
||||
|
||||
public function import(Request $request, Business $business)
|
||||
{
|
||||
$this->authorize('create', [Template::class, $business]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'file' => 'required|file|mimes:html,txt,zip',
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'category_id' => 'required|exists:template_categories,id',
|
||||
]);
|
||||
|
||||
$file = $request->file('file');
|
||||
$extension = $file->getClientOriginalExtension();
|
||||
|
||||
if ($extension === 'zip') {
|
||||
// TODO: Handle ZIP import with metadata extraction
|
||||
return back()->with('error', 'ZIP import not yet implemented');
|
||||
}
|
||||
|
||||
$html = file_get_contents($file->path());
|
||||
|
||||
$template = $this->templateService->importFromHtml($html, [
|
||||
'name' => $validated['name'],
|
||||
'description' => $validated['description'],
|
||||
'category_id' => $validated['category_id'],
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.marketing.templates.edit', ['business' => $business, 'template' => $template])
|
||||
->with('success', 'Template imported successfully');
|
||||
}
|
||||
|
||||
public function aiGenerate(Request $request, Business $business)
|
||||
{
|
||||
$this->authorize('create', [Template::class, $business]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'prompt' => 'required|string|max:1000',
|
||||
'brand_id' => 'nullable|exists:brands,id',
|
||||
]);
|
||||
|
||||
$context = ['business' => $business->name];
|
||||
|
||||
if (isset($validated['brand_id'])) {
|
||||
$brand = Brand::where('business_id', $business->id)
|
||||
->findOrFail($validated['brand_id']);
|
||||
$context['brand'] = $brand->name;
|
||||
}
|
||||
|
||||
$content = $this->aiContentService->generateEmailContent(
|
||||
$validated['prompt'],
|
||||
$context
|
||||
);
|
||||
|
||||
return response()->json(['content' => $content]);
|
||||
}
|
||||
|
||||
public function aiSubjectLines(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'content' => 'required|string',
|
||||
'count' => 'nullable|integer|min:3|max:20',
|
||||
]);
|
||||
|
||||
$subjectLines = $this->aiContentService->generateSubjectLines(
|
||||
$validated['content'],
|
||||
$validated['count'] ?? 10
|
||||
);
|
||||
|
||||
return response()->json(['subject_lines' => $subjectLines]);
|
||||
}
|
||||
|
||||
public function aiImproveCopy(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'content' => 'required|string',
|
||||
'tone' => 'required|in:professional,casual,urgent,enthusiastic,educational',
|
||||
]);
|
||||
|
||||
$improved = $this->aiContentService->improveCopy(
|
||||
$validated['content'],
|
||||
$validated['tone']
|
||||
);
|
||||
|
||||
return response()->json(['improved' => $improved]);
|
||||
}
|
||||
|
||||
public function aiCheckSpam(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'content' => 'required|string',
|
||||
]);
|
||||
|
||||
$result = $this->aiContentService->checkSpamScore($validated['content']);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
}
|
||||
53
app/Http/Controllers/Seller/MarketplacePreviewController.php
Normal file
53
app/Http/Controllers/Seller/MarketplacePreviewController.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
180
app/Http/Controllers/ViewAsController.php
Normal file
180
app/Http/Controllers/ViewAsController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
56
app/Http/Controllers/ViewSwitcherController.php
Normal file
56
app/Http/Controllers/ViewSwitcherController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
29
app/Http/Middleware/UpdateLastLogin.php
Normal file
29
app/Http/Middleware/UpdateLastLogin.php
Normal 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);
|
||||
}
|
||||
}
|
||||
98
app/Http/Middleware/ViewAsMiddleware.php
Normal file
98
app/Http/Middleware/ViewAsMiddleware.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
132
app/Jobs/Analytics/CalculateEngagementScore.php
Normal file
132
app/Jobs/Analytics/CalculateEngagementScore.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Analytics;
|
||||
|
||||
use App\Models\Analytics\BuyerEngagementScore;
|
||||
use App\Models\Analytics\IntentSignal;
|
||||
use App\Models\Analytics\ProductView;
|
||||
use App\Models\Analytics\UserSession;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class CalculateEngagementScore implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
protected $businessId;
|
||||
|
||||
protected $buyerBusinessId;
|
||||
|
||||
public function __construct(int $businessId, int $buyerBusinessId)
|
||||
{
|
||||
$this->businessId = $businessId;
|
||||
$this->buyerBusinessId = $buyerBusinessId;
|
||||
$this->onQueue('analytics');
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$score = BuyerEngagementScore::firstOrNew([
|
||||
'business_id' => $this->businessId,
|
||||
'buyer_business_id' => $this->buyerBusinessId,
|
||||
]);
|
||||
|
||||
// Calculate recency score (0-100 points, weighted 25%)
|
||||
$lastInteraction = ProductView::where('business_id', $this->businessId)
|
||||
->where('buyer_business_id', $this->buyerBusinessId)
|
||||
->max('viewed_at');
|
||||
|
||||
if ($lastInteraction) {
|
||||
$daysSince = now()->diffInDays($lastInteraction);
|
||||
$score->days_since_last_interaction = $daysSince;
|
||||
$score->last_interaction_at = $lastInteraction;
|
||||
|
||||
// Score decreases as days increase
|
||||
if ($daysSince <= 1) {
|
||||
$score->recency_score = 100;
|
||||
} elseif ($daysSince <= 7) {
|
||||
$score->recency_score = 80;
|
||||
} elseif ($daysSince <= 30) {
|
||||
$score->recency_score = 40;
|
||||
} else {
|
||||
$score->recency_score = 0;
|
||||
}
|
||||
} else {
|
||||
$score->recency_score = 0;
|
||||
}
|
||||
|
||||
// Calculate frequency score (0-100 points, weighted 25%)
|
||||
$sessions30d = UserSession::where('business_id', $this->businessId)
|
||||
->where('buyer_business_id', $this->buyerBusinessId)
|
||||
->where('started_at', '>=', now()->subDays(30))
|
||||
->count();
|
||||
|
||||
$score->sessions_30d = $sessions30d;
|
||||
|
||||
if ($sessions30d >= 20) {
|
||||
$score->frequency_score = 100;
|
||||
} elseif ($sessions30d >= 10) {
|
||||
$score->frequency_score = 80;
|
||||
} elseif ($sessions30d >= 5) {
|
||||
$score->frequency_score = 60;
|
||||
} else {
|
||||
$score->frequency_score = $sessions30d * 10; // 0-40
|
||||
}
|
||||
|
||||
// Calculate depth score (0-100 points, weighted 30%)
|
||||
$productViews30d = ProductView::where('business_id', $this->businessId)
|
||||
->where('buyer_business_id', $this->buyerBusinessId)
|
||||
->where('viewed_at', '>=', now()->subDays(30))
|
||||
->count();
|
||||
|
||||
$highEngagement = ProductView::where('business_id', $this->businessId)
|
||||
->where('buyer_business_id', $this->buyerBusinessId)
|
||||
->where('viewed_at', '>=', now()->subDays(30))
|
||||
->where(function ($q) {
|
||||
$q->where('zoomed_image', true)
|
||||
->orWhere('watched_video', true)
|
||||
->orWhere('downloaded_spec', true)
|
||||
->orWhere('added_to_cart', true);
|
||||
})
|
||||
->count();
|
||||
|
||||
$score->product_views_30d = $productViews30d;
|
||||
|
||||
// Base depth score on engagement rate
|
||||
$engagementRate = $productViews30d > 0 ? ($highEngagement / $productViews30d) * 100 : 0;
|
||||
$viewScore = min(50, $productViews30d * 2); // Up to 50 points for views
|
||||
$interactionScore = min(50, $engagementRate / 2); // Up to 50 points for engagement rate
|
||||
|
||||
$score->depth_score = min(100, $viewScore + $interactionScore);
|
||||
|
||||
// Calculate intent score (0-100 points, weighted 20%)
|
||||
$intentSignals = IntentSignal::where('business_id', $this->businessId)
|
||||
->where('buyer_business_id', $this->buyerBusinessId)
|
||||
->where('detected_at', '>=', now()->subDays(30))
|
||||
->sum('signal_strength');
|
||||
|
||||
$score->intent_score = min(100, $intentSignals * 10);
|
||||
|
||||
// Calculate total score (weighted average)
|
||||
$score->calculateScore();
|
||||
|
||||
// Determine tier based on score
|
||||
if ($score->score >= 80) {
|
||||
$score->score_tier = 'hot';
|
||||
} elseif ($score->score >= 60) {
|
||||
$score->score_tier = 'warm';
|
||||
} elseif ($score->score >= 40) {
|
||||
$score->score_tier = 'cool';
|
||||
} elseif ($score->score >= 20) {
|
||||
$score->score_tier = 'cold';
|
||||
} else {
|
||||
$score->score_tier = 'inactive';
|
||||
}
|
||||
|
||||
$score->calculated_at = now();
|
||||
$score->save();
|
||||
}
|
||||
}
|
||||
249
app/Jobs/CalculateEngagementScore.php
Normal file
249
app/Jobs/CalculateEngagementScore.php
Normal 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;
|
||||
}
|
||||
}
|
||||
63
app/Jobs/Marketing/SendBroadcastJob.php
Normal file
63
app/Jobs/Marketing/SendBroadcastJob.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Marketing;
|
||||
|
||||
use App\Models\Broadcast;
|
||||
use App\Services\Marketing\BroadcastService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class SendBroadcastJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 1;
|
||||
|
||||
public int $timeout = 600; // 10 minutes
|
||||
|
||||
public function __construct(
|
||||
public Broadcast $broadcast
|
||||
) {}
|
||||
|
||||
public function handle(BroadcastService $service): void
|
||||
{
|
||||
try {
|
||||
Log::info('Starting broadcast send', [
|
||||
'broadcast_id' => $this->broadcast->id,
|
||||
'name' => $this->broadcast->name,
|
||||
]);
|
||||
|
||||
$service->processBroadcastSending($this->broadcast);
|
||||
|
||||
Log::info('Broadcast queued successfully', [
|
||||
'broadcast_id' => $this->broadcast->id,
|
||||
'recipients' => $this->broadcast->total_recipients,
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Broadcast send failed', [
|
||||
'broadcast_id' => $this->broadcast->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
$this->broadcast->update([
|
||||
'status' => 'failed',
|
||||
'finished_sending_at' => now(),
|
||||
]);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function failed(\Throwable $exception): void
|
||||
{
|
||||
Log::error('SendBroadcastJob failed permanently', [
|
||||
'broadcast_id' => $this->broadcast->id,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
57
app/Jobs/Marketing/SendBroadcastMessageJob.php
Normal file
57
app/Jobs/Marketing/SendBroadcastMessageJob.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Marketing;
|
||||
|
||||
use App\Models\Broadcast;
|
||||
use App\Models\BroadcastRecipient;
|
||||
use App\Services\Marketing\BroadcastService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class SendBroadcastMessageJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public int $timeout = 60;
|
||||
|
||||
public int $backoff = 30; // Retry after 30 seconds
|
||||
|
||||
public function __construct(
|
||||
public Broadcast $broadcast,
|
||||
public BroadcastRecipient $recipient
|
||||
) {}
|
||||
|
||||
public function handle(BroadcastService $service): void
|
||||
{
|
||||
// Check if broadcast is still sending
|
||||
if ($this->broadcast->status !== 'sending') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if recipient still needs sending
|
||||
if (! in_array($this->recipient->status, ['pending', 'queued'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Send the message
|
||||
$service->sendToRecipient($this->broadcast, $this->recipient);
|
||||
|
||||
// Check if broadcast is complete
|
||||
$service->checkBroadcastCompletion($this->broadcast);
|
||||
}
|
||||
|
||||
public function failed(\Throwable $exception): void
|
||||
{
|
||||
$this->recipient->markAsFailed(
|
||||
$exception->getMessage(),
|
||||
$exception->getCode()
|
||||
);
|
||||
|
||||
$this->broadcast->increment('total_failed');
|
||||
}
|
||||
}
|
||||
61
app/Jobs/ProcessAnalyticsEvent.php
Normal file
61
app/Jobs/ProcessAnalyticsEvent.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
100
app/Models/Analytics/AnalyticsEvent.php
Normal file
100
app/Models/Analytics/AnalyticsEvent.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Analytics;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class AnalyticsEvent extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
const CREATED_AT = 'created_at';
|
||||
|
||||
const UPDATED_AT = null;
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'event_type',
|
||||
'event_category',
|
||||
'event_action',
|
||||
'subject_id',
|
||||
'subject_type',
|
||||
'user_id',
|
||||
'session_id',
|
||||
'fingerprint',
|
||||
'url',
|
||||
'referrer',
|
||||
'utm_source',
|
||||
'utm_medium',
|
||||
'utm_campaign',
|
||||
'utm_content',
|
||||
'utm_term',
|
||||
'user_agent',
|
||||
'device_type',
|
||||
'browser',
|
||||
'os',
|
||||
'ip_address',
|
||||
'country_code',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'metadata' => 'array',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* Boot the model and apply global scopes
|
||||
*/
|
||||
protected static function booted(): void
|
||||
{
|
||||
parent::booted();
|
||||
|
||||
// Auto-set business_id on creation
|
||||
static::creating(function ($model) {
|
||||
if (! $model->business_id) {
|
||||
$model->business_id = currentBusinessId();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Relationships
|
||||
*/
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scopes
|
||||
*/
|
||||
public function scopeForBusiness($query, $businessId)
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
public function scopeOfType($query, string $type)
|
||||
{
|
||||
return $query->where('event_type', $type);
|
||||
}
|
||||
|
||||
public function scopeForSubject($query, string $type, int $id)
|
||||
{
|
||||
return $query->where('subject_type', $type)
|
||||
->where('subject_id', $id);
|
||||
}
|
||||
|
||||
public function scopeDateRange($query, $startDate, $endDate)
|
||||
{
|
||||
return $query->whereBetween('created_at', [$startDate, $endDate]);
|
||||
}
|
||||
}
|
||||
134
app/Models/Analytics/BuyerEngagementScore.php
Normal file
134
app/Models/Analytics/BuyerEngagementScore.php
Normal 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;
|
||||
}
|
||||
}
|
||||
74
app/Models/Analytics/ClickTracking.php
Normal file
74
app/Models/Analytics/ClickTracking.php
Normal 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);
|
||||
}
|
||||
}
|
||||
81
app/Models/Analytics/EmailCampaign.php
Normal file
81
app/Models/Analytics/EmailCampaign.php
Normal 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);
|
||||
}
|
||||
}
|
||||
59
app/Models/Analytics/EmailClick.php
Normal file
59
app/Models/Analytics/EmailClick.php
Normal 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);
|
||||
}
|
||||
}
|
||||
157
app/Models/Analytics/EmailInteraction.php
Normal file
157
app/Models/Analytics/EmailInteraction.php
Normal 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);
|
||||
}
|
||||
}
|
||||
101
app/Models/Analytics/IntentSignal.php
Normal file
101
app/Models/Analytics/IntentSignal.php
Normal 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);
|
||||
}
|
||||
}
|
||||
87
app/Models/Analytics/ProductView.php
Normal file
87
app/Models/Analytics/ProductView.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Analytics;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Models\Product;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ProductView extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'product_id',
|
||||
'user_id',
|
||||
'buyer_business_id',
|
||||
'session_id',
|
||||
'viewed_at',
|
||||
'time_on_page',
|
||||
'scroll_depth',
|
||||
'zoomed_image',
|
||||
'watched_video',
|
||||
'downloaded_spec',
|
||||
'added_to_cart',
|
||||
'added_to_wishlist',
|
||||
'source',
|
||||
'referrer',
|
||||
'utm_campaign',
|
||||
'device_type',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'viewed_at' => 'datetime',
|
||||
'zoomed_image' => 'boolean',
|
||||
'watched_video' => 'boolean',
|
||||
'downloaded_spec' => 'boolean',
|
||||
'added_to_cart' => 'boolean',
|
||||
'added_to_wishlist' => 'boolean',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
parent::booted();
|
||||
|
||||
static::creating(function ($model) {
|
||||
if (! $model->business_id) {
|
||||
$model->business_id = currentBusinessId();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function buyerBusiness(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class, 'buyer_business_id');
|
||||
}
|
||||
|
||||
public function scopeForBusiness($query, $businessId)
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
public function scopeHighEngagement($query)
|
||||
{
|
||||
return $query->where(function ($q) {
|
||||
$q->where('time_on_page', '>', 30)
|
||||
->orWhere('zoomed_image', true)
|
||||
->orWhere('watched_video', true)
|
||||
->orWhere('downloaded_spec', true);
|
||||
});
|
||||
}
|
||||
}
|
||||
100
app/Models/Analytics/UserSession.php
Normal file
100
app/Models/Analytics/UserSession.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
@@ -163,7 +175,28 @@ class Brand extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* Get route key (slug for URLs)
|
||||
* Generate slug from name
|
||||
*/
|
||||
public function generateSlug(): string
|
||||
{
|
||||
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 slug for routing (unlike products which use hashid)
|
||||
*/
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
@@ -171,11 +204,12 @@ class Brand extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate slug from name
|
||||
* Get the storage path for this brand's assets
|
||||
* Format: {hashid}/ (e.g., "52kn5/")
|
||||
*/
|
||||
public function generateSlug(): string
|
||||
public function getStoragePath(): string
|
||||
{
|
||||
return Str::slug($this->name);
|
||||
return $this->hashid.'/';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
171
app/Models/Broadcast.php
Normal file
171
app/Models/Broadcast.php
Normal file
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Broadcast extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'created_by_user_id',
|
||||
'name',
|
||||
'description',
|
||||
'type',
|
||||
'channel',
|
||||
'template_id',
|
||||
'subject',
|
||||
'content',
|
||||
'audience_ids',
|
||||
'segment_rules',
|
||||
'include_all',
|
||||
'exclude_audience_ids',
|
||||
'scheduled_at',
|
||||
'timezone',
|
||||
'recurring_pattern',
|
||||
'recurring_ends_at',
|
||||
'status',
|
||||
'started_sending_at',
|
||||
'finished_sending_at',
|
||||
'total_recipients',
|
||||
'total_sent',
|
||||
'total_delivered',
|
||||
'total_failed',
|
||||
'total_opened',
|
||||
'total_clicked',
|
||||
'total_unsubscribed',
|
||||
'track_opens',
|
||||
'track_clicks',
|
||||
'send_rate_limit',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'audience_ids' => 'array',
|
||||
'segment_rules' => 'array',
|
||||
'exclude_audience_ids' => 'array',
|
||||
'include_all' => 'boolean',
|
||||
'scheduled_at' => 'datetime',
|
||||
'recurring_ends_at' => 'datetime',
|
||||
'started_sending_at' => 'datetime',
|
||||
'finished_sending_at' => 'datetime',
|
||||
'recurring_pattern' => 'array',
|
||||
'track_opens' => 'boolean',
|
||||
'track_clicks' => 'boolean',
|
||||
'metadata' => 'array',
|
||||
];
|
||||
|
||||
// Relationships
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by_user_id');
|
||||
}
|
||||
|
||||
public function template(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(MarketingTemplate::class, 'template_id');
|
||||
}
|
||||
|
||||
public function recipients(): HasMany
|
||||
{
|
||||
return $this->hasMany(BroadcastRecipient::class);
|
||||
}
|
||||
|
||||
public function events(): HasMany
|
||||
{
|
||||
return $this->hasMany(BroadcastEvent::class);
|
||||
}
|
||||
|
||||
public function audiences()
|
||||
{
|
||||
if (! $this->audience_ids) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return MarketingAudience::whereIn('id', $this->audience_ids)->get();
|
||||
}
|
||||
|
||||
// Scopes
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->whereIn('status', ['scheduled', 'sending']);
|
||||
}
|
||||
|
||||
public function scopeScheduled($query)
|
||||
{
|
||||
return $query->where('status', 'scheduled')
|
||||
->where('scheduled_at', '<=', now());
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
public function isDraft(): bool
|
||||
{
|
||||
return $this->status === 'draft';
|
||||
}
|
||||
|
||||
public function isScheduled(): bool
|
||||
{
|
||||
return $this->status === 'scheduled';
|
||||
}
|
||||
|
||||
public function isSending(): bool
|
||||
{
|
||||
return $this->status === 'sending';
|
||||
}
|
||||
|
||||
public function isSent(): bool
|
||||
{
|
||||
return $this->status === 'sent';
|
||||
}
|
||||
|
||||
public function canBeSent(): bool
|
||||
{
|
||||
return in_array($this->status, ['draft', 'scheduled', 'paused']);
|
||||
}
|
||||
|
||||
public function canBeCancelled(): bool
|
||||
{
|
||||
return in_array($this->status, ['draft', 'scheduled', 'paused']);
|
||||
}
|
||||
|
||||
public function getOpenRate(): float
|
||||
{
|
||||
if ($this->total_delivered === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return round(($this->total_opened / $this->total_delivered) * 100, 2);
|
||||
}
|
||||
|
||||
public function getClickRate(): float
|
||||
{
|
||||
if ($this->total_delivered === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return round(($this->total_clicked / $this->total_delivered) * 100, 2);
|
||||
}
|
||||
|
||||
public function getDeliveryRate(): float
|
||||
{
|
||||
if ($this->total_sent === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return round(($this->total_delivered / $this->total_sent) * 100, 2);
|
||||
}
|
||||
}
|
||||
60
app/Models/BroadcastEvent.php
Normal file
60
app/Models/BroadcastEvent.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class BroadcastEvent extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'broadcast_id',
|
||||
'user_id',
|
||||
'event',
|
||||
'link_url',
|
||||
'user_agent',
|
||||
'ip_address',
|
||||
'device_type',
|
||||
'metadata',
|
||||
'occurred_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'metadata' => 'array',
|
||||
'occurred_at' => 'datetime',
|
||||
];
|
||||
|
||||
// Relationships
|
||||
|
||||
public function broadcast(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Broadcast::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
public function isOpen(): bool
|
||||
{
|
||||
return $this->event === 'opened';
|
||||
}
|
||||
|
||||
public function isClick(): bool
|
||||
{
|
||||
return $this->event === 'clicked';
|
||||
}
|
||||
|
||||
public function isUnsubscribe(): bool
|
||||
{
|
||||
return $this->event === 'unsubscribed';
|
||||
}
|
||||
}
|
||||
83
app/Models/BroadcastRecipient.php
Normal file
83
app/Models/BroadcastRecipient.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class BroadcastRecipient extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'broadcast_id',
|
||||
'user_id',
|
||||
'status',
|
||||
'queued_at',
|
||||
'sent_at',
|
||||
'delivered_at',
|
||||
'failed_at',
|
||||
'error_message',
|
||||
'error_code',
|
||||
'provider_message_id',
|
||||
'provider_response',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'queued_at' => 'datetime',
|
||||
'sent_at' => 'datetime',
|
||||
'delivered_at' => 'datetime',
|
||||
'failed_at' => 'datetime',
|
||||
'provider_response' => 'array',
|
||||
];
|
||||
|
||||
// Relationships
|
||||
|
||||
public function broadcast(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Broadcast::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
public function markAsQueued(): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => 'queued',
|
||||
'queued_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function markAsSent(?string $providerId = null): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => 'sent',
|
||||
'sent_at' => now(),
|
||||
'provider_message_id' => $providerId,
|
||||
]);
|
||||
}
|
||||
|
||||
public function markAsDelivered(): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => 'delivered',
|
||||
'delivered_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function markAsFailed(string $error, ?string $code = null): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => 'failed',
|
||||
'failed_at' => now(),
|
||||
'error_message' => $error,
|
||||
'error_code' => $code,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
43
app/Models/ComponentCategory.php
Normal file
43
app/Models/ComponentCategory.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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([
|
||||
|
||||
40
app/Models/Marketing/EmailEvent.php
Normal file
40
app/Models/Marketing/EmailEvent.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Marketing;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class EmailEvent extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'user_id',
|
||||
'message_id',
|
||||
'email',
|
||||
'event_type',
|
||||
'link_url',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'metadata' => 'array',
|
||||
];
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
52
app/Models/Marketing/MarketingEngagement.php
Normal file
52
app/Models/Marketing/MarketingEngagement.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Marketing;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
class MarketingEngagement extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'user_id',
|
||||
'session_id',
|
||||
'engagement_type',
|
||||
'trackable_type',
|
||||
'trackable_id',
|
||||
'url',
|
||||
'properties',
|
||||
'value',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'properties' => 'array',
|
||||
'value' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function session(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(MarketingSession::class, 'session_id');
|
||||
}
|
||||
|
||||
public function trackable(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
}
|
||||
50
app/Models/Marketing/MarketingSession.php
Normal file
50
app/Models/Marketing/MarketingSession.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Marketing;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class MarketingSession extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'user_id',
|
||||
'session_id',
|
||||
'channel',
|
||||
'source',
|
||||
'source_type',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
'metadata',
|
||||
'started_at',
|
||||
'ended_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'metadata' => 'array',
|
||||
'started_at' => 'datetime',
|
||||
'ended_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function engagements(): HasMany
|
||||
{
|
||||
return $this->hasMany(MarketingEngagement::class, 'session_id');
|
||||
}
|
||||
}
|
||||
223
app/Models/Marketing/Template.php
Normal file
223
app/Models/Marketing/Template.php
Normal file
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Marketing;
|
||||
|
||||
use App\Models\Brand;
|
||||
use App\Models\Business;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Template extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'category_id',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'name',
|
||||
'description',
|
||||
'thumbnail',
|
||||
'design_json',
|
||||
'mjml_content',
|
||||
'html_content',
|
||||
'plain_text',
|
||||
'is_system_template',
|
||||
'is_public',
|
||||
'template_type',
|
||||
'tags',
|
||||
'usage_count',
|
||||
'last_used_at',
|
||||
'version',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'design_json' => 'array',
|
||||
'tags' => 'array',
|
||||
'is_system_template' => 'boolean',
|
||||
'is_public' => 'boolean',
|
||||
'last_used_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected $appends = ['is_editable'];
|
||||
|
||||
// Relationships
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(TemplateCategory::class, 'category_id');
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function updater(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'updated_by');
|
||||
}
|
||||
|
||||
public function versions(): HasMany
|
||||
{
|
||||
return $this->hasMany(TemplateVersion::class)->orderBy('version_number', 'desc');
|
||||
}
|
||||
|
||||
public function analytics(): HasMany
|
||||
{
|
||||
return $this->hasMany(TemplateAnalytics::class);
|
||||
}
|
||||
|
||||
public function brands(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Brand::class, 'brand_templates')
|
||||
->withPivot(['is_favorite', 'usage_count', 'last_used_at', 'added_by'])
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function broadcasts(): HasMany
|
||||
{
|
||||
return $this->hasMany(Broadcast::class, 'template_id');
|
||||
}
|
||||
|
||||
// Scopes
|
||||
|
||||
public function scopeForBusiness($query, $businessId)
|
||||
{
|
||||
return $query->where(function ($q) use ($businessId) {
|
||||
$q->where('business_id', $businessId)
|
||||
->orWhere('is_system_template', true);
|
||||
});
|
||||
}
|
||||
|
||||
public function scopeSystemTemplates($query)
|
||||
{
|
||||
return $query->where('is_system_template', true);
|
||||
}
|
||||
|
||||
public function scopeUserTemplates($query, $businessId)
|
||||
{
|
||||
return $query->where('business_id', $businessId)
|
||||
->where('is_system_template', false);
|
||||
}
|
||||
|
||||
public function scopeByType($query, string $type)
|
||||
{
|
||||
return $query->where('template_type', $type);
|
||||
}
|
||||
|
||||
public function scopeByCategory($query, $categoryId)
|
||||
{
|
||||
return $query->where('category_id', $categoryId);
|
||||
}
|
||||
|
||||
public function scopeRecent($query)
|
||||
{
|
||||
return $query->orderBy('created_at', 'desc');
|
||||
}
|
||||
|
||||
public function scopePopular($query)
|
||||
{
|
||||
return $query->orderBy('usage_count', 'desc');
|
||||
}
|
||||
|
||||
public function scopeWithTags($query, array $tags)
|
||||
{
|
||||
return $query->where(function ($q) use ($tags) {
|
||||
foreach ($tags as $tag) {
|
||||
$q->orWhereJsonContains('tags', $tag);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Helper Methods
|
||||
|
||||
public function getIsEditableAttribute(): bool
|
||||
{
|
||||
// System templates are read-only
|
||||
if ($this->is_system_template) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// User can edit their own business templates
|
||||
return $this->business_id === currentBusiness()?->id;
|
||||
}
|
||||
|
||||
public function canBeDeleted(): bool
|
||||
{
|
||||
// System templates cannot be deleted
|
||||
if ($this->is_system_template) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if template is in use
|
||||
if ($this->usage_count > 0 || $this->broadcasts()->exists()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function incrementUsage(): void
|
||||
{
|
||||
$this->increment('usage_count');
|
||||
$this->update(['last_used_at' => now()]);
|
||||
}
|
||||
|
||||
public function createVersion(?string $notes = null): TemplateVersion
|
||||
{
|
||||
return $this->versions()->create([
|
||||
'version_number' => $this->version,
|
||||
'version_name' => "Version {$this->version}",
|
||||
'change_notes' => $notes,
|
||||
'design_json' => $this->design_json,
|
||||
'mjml_content' => $this->mjml_content,
|
||||
'html_content' => $this->html_content,
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function restoreVersion(TemplateVersion $version): void
|
||||
{
|
||||
$this->update([
|
||||
'design_json' => $version->design_json,
|
||||
'mjml_content' => $version->mjml_content,
|
||||
'html_content' => $version->html_content,
|
||||
'version' => $this->version + 1,
|
||||
]);
|
||||
|
||||
$this->createVersion("Restored from Version {$version->version_number}");
|
||||
}
|
||||
|
||||
public function duplicate(string $newName): self
|
||||
{
|
||||
$duplicate = $this->replicate();
|
||||
$duplicate->name = $newName;
|
||||
$duplicate->is_system_template = false;
|
||||
$duplicate->usage_count = 0;
|
||||
$duplicate->last_used_at = null;
|
||||
$duplicate->created_by = auth()->id();
|
||||
$duplicate->save();
|
||||
|
||||
return $duplicate;
|
||||
}
|
||||
|
||||
public function getAnalyticsForBusiness($businessId, $brandId = null): ?TemplateAnalytics
|
||||
{
|
||||
return $this->analytics()
|
||||
->where('business_id', $businessId)
|
||||
->where('brand_id', $brandId)
|
||||
->first();
|
||||
}
|
||||
}
|
||||
96
app/Models/Marketing/TemplateAnalytics.php
Normal file
96
app/Models/Marketing/TemplateAnalytics.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Marketing;
|
||||
|
||||
use App\Models\Brand;
|
||||
use App\Models\Business;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class TemplateAnalytics extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'template_id',
|
||||
'business_id',
|
||||
'brand_id',
|
||||
'times_used',
|
||||
'total_sends',
|
||||
'total_opens',
|
||||
'total_clicks',
|
||||
'total_bounces',
|
||||
'total_unsubscribes',
|
||||
'avg_open_rate',
|
||||
'avg_click_rate',
|
||||
'avg_bounce_rate',
|
||||
'first_used_at',
|
||||
'last_used_at',
|
||||
'best_subject_line',
|
||||
'best_open_rate',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'first_used_at' => 'datetime',
|
||||
'last_used_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function template(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Template::class);
|
||||
}
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function brand(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Brand::class);
|
||||
}
|
||||
|
||||
public function updateFromBroadcast(Broadcast $broadcast): void
|
||||
{
|
||||
$this->increment('times_used');
|
||||
$this->increment('total_sends', $broadcast->sent_count);
|
||||
|
||||
// Update engagement metrics
|
||||
$opens = $broadcast->interactions()->where('status', 'opened')->count();
|
||||
$clicks = $broadcast->interactions()->where('status', 'clicked')->count();
|
||||
$bounces = $broadcast->interactions()->where('status', 'bounced')->count();
|
||||
|
||||
$this->increment('total_opens', $opens);
|
||||
$this->increment('total_clicks', $clicks);
|
||||
$this->increment('total_bounces', $bounces);
|
||||
|
||||
// Recalculate averages
|
||||
$this->updateAverages();
|
||||
|
||||
// Update best performing
|
||||
$openRate = $broadcast->open_rate;
|
||||
if ($openRate > $this->best_open_rate) {
|
||||
$this->update([
|
||||
'best_open_rate' => $openRate,
|
||||
'best_subject_line' => $broadcast->subject,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->update([
|
||||
'last_used_at' => now(),
|
||||
'first_used_at' => $this->first_used_at ?? now(),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function updateAverages(): void
|
||||
{
|
||||
if ($this->total_sends > 0) {
|
||||
$this->update([
|
||||
'avg_open_rate' => round(($this->total_opens / $this->total_sends) * 100, 2),
|
||||
'avg_click_rate' => round(($this->total_clicks / $this->total_sends) * 100, 2),
|
||||
'avg_bounce_rate' => round(($this->total_bounces / $this->total_sends) * 100, 2),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
208
app/Models/PermissionAuditLog.php
Normal file
208
app/Models/PermissionAuditLog.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
43
app/Models/ProductCategory.php
Normal file
43
app/Models/ProductCategory.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
275
app/Models/ViewAsSession.php
Normal file
275
app/Models/ViewAsSession.php
Normal 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}";
|
||||
}
|
||||
}
|
||||
263
app/Services/Analytics/AnalyticsTracker.php
Normal file
263
app/Services/Analytics/AnalyticsTracker.php
Normal 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;
|
||||
}
|
||||
}
|
||||
340
app/Services/AnalyticsTracker.php
Normal file
340
app/Services/AnalyticsTracker.php
Normal file
@@ -0,0 +1,340 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Events\HighIntentBuyerDetected;
|
||||
use App\Helpers\BusinessHelper;
|
||||
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\Product;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Request;
|
||||
|
||||
class AnalyticsTracker
|
||||
{
|
||||
protected ?string $sessionId = null;
|
||||
|
||||
protected ?int $businessId = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->sessionId = session()->getId();
|
||||
$this->businessId = BusinessHelper::currentId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a product view with engagement signals
|
||||
*/
|
||||
public function trackProductView(
|
||||
Product $product,
|
||||
array $signals = []
|
||||
): ProductView {
|
||||
// Get seller business from product -> brand -> business
|
||||
$sellerBusiness = BusinessHelper::fromProduct($product);
|
||||
|
||||
$productView = ProductView::create([
|
||||
'business_id' => $sellerBusiness->id,
|
||||
'product_id' => $product->id,
|
||||
'user_id' => Auth::id(),
|
||||
'buyer_business_id' => $this->businessId,
|
||||
'session_id' => $this->sessionId,
|
||||
'viewed_at' => now(),
|
||||
'time_on_page' => $signals['time_on_page'] ?? null,
|
||||
'scroll_depth' => $signals['scroll_depth'] ?? null,
|
||||
'zoomed_image' => $signals['zoomed_image'] ?? false,
|
||||
'watched_video' => $signals['watched_video'] ?? false,
|
||||
'downloaded_spec' => $signals['downloaded_spec'] ?? false,
|
||||
'added_to_cart' => $signals['added_to_cart'] ?? false,
|
||||
'added_to_wishlist' => $signals['added_to_wishlist'] ?? false,
|
||||
'source' => $signals['source'] ?? null,
|
||||
'referrer' => Request::header('referer'),
|
||||
'utm_campaign' => Request::input('utm_campaign'),
|
||||
'device_type' => $this->getDeviceType(),
|
||||
]);
|
||||
|
||||
// Also create analytics event
|
||||
$this->trackEvent('product_view', 'product', 'view', $product->id, Product::class);
|
||||
|
||||
// Detect high-intent signals
|
||||
$this->detectIntentSignals($productView, $sellerBusiness->id);
|
||||
|
||||
return $productView;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a click event
|
||||
*/
|
||||
public function trackClick(
|
||||
string $elementType,
|
||||
?int $elementId = null,
|
||||
?string $elementLabel = null,
|
||||
?string $url = null,
|
||||
array $metadata = []
|
||||
): ClickTracking {
|
||||
return ClickTracking::create([
|
||||
'business_id' => $this->businessId,
|
||||
'user_id' => Auth::id(),
|
||||
'session_id' => $this->sessionId,
|
||||
'element_type' => $elementType,
|
||||
'element_id' => $elementId,
|
||||
'element_label' => $elementLabel,
|
||||
'url' => $url,
|
||||
'page_url' => Request::url(),
|
||||
'clicked_at' => now(),
|
||||
'metadata' => $metadata,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track email interaction
|
||||
*/
|
||||
public function trackEmailInteraction(
|
||||
string $campaignId,
|
||||
string $action,
|
||||
array $data = []
|
||||
): void {
|
||||
$this->trackEvent(
|
||||
"email_{$action}",
|
||||
'email',
|
||||
$action,
|
||||
$campaignId,
|
||||
'App\Models\Analytics\EmailCampaign',
|
||||
$data
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a generic analytics event
|
||||
*/
|
||||
public function trackEvent(
|
||||
string $eventType,
|
||||
string $category,
|
||||
string $action,
|
||||
?int $subjectId = null,
|
||||
?string $subjectType = null,
|
||||
array $metadata = []
|
||||
): AnalyticsEvent {
|
||||
return AnalyticsEvent::create([
|
||||
'business_id' => $this->businessId,
|
||||
'event_type' => $eventType,
|
||||
'event_category' => $category,
|
||||
'event_action' => $action,
|
||||
'subject_id' => $subjectId,
|
||||
'subject_type' => $subjectType,
|
||||
'user_id' => Auth::id(),
|
||||
'session_id' => $this->sessionId,
|
||||
'fingerprint' => $this->getFingerprint(),
|
||||
'url' => Request::url(),
|
||||
'referrer' => Request::header('referer'),
|
||||
'utm_source' => Request::input('utm_source'),
|
||||
'utm_medium' => Request::input('utm_medium'),
|
||||
'utm_campaign' => Request::input('utm_campaign'),
|
||||
'utm_content' => Request::input('utm_content'),
|
||||
'utm_term' => Request::input('utm_term'),
|
||||
'user_agent' => Request::header('User-Agent'),
|
||||
'device_type' => $this->getDeviceType(),
|
||||
'browser' => $this->getBrowser(),
|
||||
'os' => $this->getOS(),
|
||||
'ip_address' => Request::ip(),
|
||||
'country_code' => null, // Can be populated via GeoIP service
|
||||
'metadata' => $metadata,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start or update user session
|
||||
*/
|
||||
public function startSession(): UserSession
|
||||
{
|
||||
$session = UserSession::firstOrNew([
|
||||
'session_id' => $this->sessionId,
|
||||
]);
|
||||
|
||||
if (! $session->exists) {
|
||||
$session->fill([
|
||||
'business_id' => $this->businessId,
|
||||
'user_id' => Auth::id(),
|
||||
'fingerprint' => $this->getFingerprint(),
|
||||
'started_at' => now(),
|
||||
'last_activity_at' => now(),
|
||||
'entry_url' => Request::url(),
|
||||
'referrer' => Request::header('referer'),
|
||||
'utm_source' => Request::input('utm_source'),
|
||||
'utm_medium' => Request::input('utm_medium'),
|
||||
'utm_campaign' => Request::input('utm_campaign'),
|
||||
'device_type' => $this->getDeviceType(),
|
||||
'browser' => $this->getBrowser(),
|
||||
'os' => $this->getOS(),
|
||||
'country_code' => null,
|
||||
]);
|
||||
} else {
|
||||
$session->updateActivity();
|
||||
}
|
||||
|
||||
$session->save();
|
||||
|
||||
return $session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update session with page view
|
||||
*/
|
||||
public function updateSessionPageView(): void
|
||||
{
|
||||
$session = UserSession::where('business_id', $this->businessId)
|
||||
->where('session_id', $this->sessionId)
|
||||
->first();
|
||||
|
||||
if ($session) {
|
||||
$session->increment('page_views');
|
||||
$session->updateActivity();
|
||||
$session->exit_url = Request::url();
|
||||
$session->save();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect high-intent signals from product views
|
||||
*/
|
||||
protected function detectIntentSignals(ProductView $productView, int $sellerBusinessId): void
|
||||
{
|
||||
$signals = [];
|
||||
|
||||
// High engagement signal
|
||||
if ($productView->time_on_page > 60 || $productView->zoomed_image || $productView->watched_video) {
|
||||
$signals[] = [
|
||||
'type' => IntentSignal::TYPE_HIGH_ENGAGEMENT,
|
||||
'strength' => IntentSignal::STRENGTH_HIGH,
|
||||
];
|
||||
}
|
||||
|
||||
// Spec download signal (very high intent)
|
||||
if ($productView->downloaded_spec) {
|
||||
$signals[] = [
|
||||
'type' => IntentSignal::TYPE_SPEC_DOWNLOAD,
|
||||
'strength' => IntentSignal::STRENGTH_CRITICAL,
|
||||
];
|
||||
}
|
||||
|
||||
// Check for repeat views
|
||||
$viewCount = ProductView::forBusiness($sellerBusinessId)
|
||||
->where('product_id', $productView->product_id)
|
||||
->where('buyer_business_id', $this->businessId)
|
||||
->count();
|
||||
|
||||
if ($viewCount > 3) {
|
||||
$signals[] = [
|
||||
'type' => IntentSignal::TYPE_REPEAT_VIEWS,
|
||||
'strength' => IntentSignal::STRENGTH_HIGH,
|
||||
];
|
||||
}
|
||||
|
||||
// Create intent signals and broadcast high-intent events
|
||||
foreach ($signals as $signal) {
|
||||
$intentSignal = IntentSignal::create([
|
||||
'business_id' => $sellerBusinessId,
|
||||
'buyer_business_id' => $this->businessId,
|
||||
'user_id' => Auth::id(),
|
||||
'signal_type' => $signal['type'],
|
||||
'signal_strength' => $signal['strength'],
|
||||
'subject_type' => Product::class,
|
||||
'subject_id' => $productView->product_id,
|
||||
'session_id' => $this->sessionId,
|
||||
'detected_at' => now(),
|
||||
'context' => [
|
||||
'product_view_id' => $productView->id,
|
||||
'time_on_page' => $productView->time_on_page,
|
||||
'view_count' => $viewCount ?? 1,
|
||||
],
|
||||
]);
|
||||
|
||||
// Broadcast high-intent signals in real-time
|
||||
if ($signal['strength'] >= IntentSignal::STRENGTH_HIGH) {
|
||||
$engagementScore = BuyerEngagementScore::forBusiness($sellerBusinessId)
|
||||
->where('buyer_business_id', $this->businessId)
|
||||
->first();
|
||||
|
||||
broadcast(new HighIntentBuyerDetected(
|
||||
$sellerBusinessId,
|
||||
$this->businessId,
|
||||
$intentSignal,
|
||||
$engagementScore
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get device type from user agent
|
||||
*/
|
||||
protected function getDeviceType(): string
|
||||
{
|
||||
$userAgent = Request::header('User-Agent');
|
||||
|
||||
if (preg_match('/mobile|android|iphone|ipad|tablet/i', $userAgent)) {
|
||||
return 'mobile';
|
||||
}
|
||||
|
||||
return 'desktop';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get browser from user agent
|
||||
*/
|
||||
protected function getBrowser(): ?string
|
||||
{
|
||||
$userAgent = Request::header('User-Agent');
|
||||
|
||||
if (preg_match('/chrome/i', $userAgent)) {
|
||||
return 'Chrome';
|
||||
} elseif (preg_match('/firefox/i', $userAgent)) {
|
||||
return 'Firefox';
|
||||
} elseif (preg_match('/safari/i', $userAgent)) {
|
||||
return 'Safari';
|
||||
} elseif (preg_match('/edge/i', $userAgent)) {
|
||||
return 'Edge';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OS from user agent
|
||||
*/
|
||||
protected function getOS(): ?string
|
||||
{
|
||||
$userAgent = Request::header('User-Agent');
|
||||
|
||||
if (preg_match('/windows/i', $userAgent)) {
|
||||
return 'Windows';
|
||||
} elseif (preg_match('/mac/i', $userAgent)) {
|
||||
return 'macOS';
|
||||
} elseif (preg_match('/linux/i', $userAgent)) {
|
||||
return 'Linux';
|
||||
} elseif (preg_match('/android/i', $userAgent)) {
|
||||
return 'Android';
|
||||
} elseif (preg_match('/ios|iphone|ipad/i', $userAgent)) {
|
||||
return 'iOS';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate fingerprint for anonymous tracking
|
||||
*/
|
||||
protected function getFingerprint(): string
|
||||
{
|
||||
$components = [
|
||||
Request::ip(),
|
||||
Request::header('User-Agent'),
|
||||
Request::header('Accept-Language'),
|
||||
];
|
||||
|
||||
return hash('sha256', implode('|', $components));
|
||||
}
|
||||
}
|
||||
228
app/Services/ImageBackgroundRemovalService.php
Normal file
228
app/Services/ImageBackgroundRemovalService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
176
app/Services/Marketing/AIContentService.php
Normal file
176
app/Services/Marketing/AIContentService.php
Normal file
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Marketing;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class AIContentService
|
||||
{
|
||||
protected string $apiKey;
|
||||
|
||||
protected string $model = 'claude-sonnet-4-20250514';
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->apiKey = config('services.anthropic.api_key');
|
||||
}
|
||||
|
||||
public function generateEmailContent(string $prompt, array $context = []): string
|
||||
{
|
||||
$systemPrompt = $this->buildSystemPrompt('email', $context);
|
||||
|
||||
return $this->complete($systemPrompt, $prompt);
|
||||
}
|
||||
|
||||
public function generateSubjectLines(string $emailContent, int $count = 10): array
|
||||
{
|
||||
$prompt = "Generate {$count} compelling email subject lines for the following email content. Return only the subject lines, one per line, without numbering:\n\n{$emailContent}";
|
||||
|
||||
$response = $this->complete(
|
||||
'You are an expert email marketer specializing in cannabis industry marketing.',
|
||||
$prompt
|
||||
);
|
||||
|
||||
return array_filter(explode("\n", $response));
|
||||
}
|
||||
|
||||
public function improveCopy(string $content, string $tone = 'professional'): string
|
||||
{
|
||||
$prompt = "Improve the following email copy to be more engaging and {$tone}. Maintain the core message but enhance clarity and impact:\n\n{$content}";
|
||||
|
||||
return $this->complete(
|
||||
'You are an expert copywriter specializing in email marketing.',
|
||||
$prompt
|
||||
);
|
||||
}
|
||||
|
||||
public function generateProductDescription(array $productData): string
|
||||
{
|
||||
$prompt = "Write a compelling product description for:\n\n";
|
||||
$prompt .= 'Product: '.($productData['name'] ?? 'Unknown')."\n";
|
||||
$prompt .= 'Category: '.($productData['category'] ?? 'Cannabis product')."\n";
|
||||
|
||||
if (isset($productData['thc'])) {
|
||||
$prompt .= 'THC: '.$productData['thc']."%\n";
|
||||
}
|
||||
if (isset($productData['cbd'])) {
|
||||
$prompt .= 'CBD: '.$productData['cbd']."%\n";
|
||||
}
|
||||
|
||||
$prompt .= "\nCreate a 2-3 paragraph description that highlights benefits and appeals to cannabis consumers.";
|
||||
|
||||
return $this->complete(
|
||||
'You are an expert product copywriter for the cannabis industry.',
|
||||
$prompt
|
||||
);
|
||||
}
|
||||
|
||||
public function generateCTA(string $goal, string $context = ''): array
|
||||
{
|
||||
$prompt = "Generate 5 different call-to-action button texts for: {$goal}";
|
||||
if ($context) {
|
||||
$prompt .= "\nContext: {$context}";
|
||||
}
|
||||
$prompt .= "\n\nReturn only the CTA texts, one per line, without numbering. Keep each CTA under 4 words.";
|
||||
|
||||
$response = $this->complete(
|
||||
'You are an expert at writing compelling call-to-action copy.',
|
||||
$prompt
|
||||
);
|
||||
|
||||
return array_filter(explode("\n", $response));
|
||||
}
|
||||
|
||||
public function adjustTone(string $content, string $targetTone): string
|
||||
{
|
||||
$tones = [
|
||||
'professional' => 'formal, business-like, and authoritative',
|
||||
'casual' => 'friendly, conversational, and approachable',
|
||||
'urgent' => 'time-sensitive, action-oriented, and compelling',
|
||||
'enthusiastic' => 'excited, energetic, and positive',
|
||||
'educational' => 'informative, clear, and helpful',
|
||||
];
|
||||
|
||||
$toneDescription = $tones[$targetTone] ?? $targetTone;
|
||||
|
||||
$prompt = "Rewrite the following content to have a {$toneDescription} tone. Maintain the core message:\n\n{$content}";
|
||||
|
||||
return $this->complete(
|
||||
'You are an expert content editor.',
|
||||
$prompt
|
||||
);
|
||||
}
|
||||
|
||||
public function checkSpamScore(string $content): array
|
||||
{
|
||||
$prompt = "Analyze this email content for spam trigger words and phrases. Provide:\n";
|
||||
$prompt .= "1. Spam score (0-10, where 0 is good and 10 is very spammy)\n";
|
||||
$prompt .= "2. List of problematic words/phrases found\n";
|
||||
$prompt .= "3. Suggestions to improve\n\n";
|
||||
$prompt .= "Content:\n{$content}\n\n";
|
||||
$prompt .= 'Respond in JSON format: {"score": 0, "triggers": [], "suggestions": []}';
|
||||
|
||||
$response = $this->complete(
|
||||
'You are an email deliverability expert.',
|
||||
$prompt
|
||||
);
|
||||
|
||||
$response = trim($response);
|
||||
$response = preg_replace('/```json\n?/', '', $response);
|
||||
$response = preg_replace('/```\n?/', '', $response);
|
||||
|
||||
return json_decode($response, true) ?? [
|
||||
'score' => 0,
|
||||
'triggers' => [],
|
||||
'suggestions' => [],
|
||||
];
|
||||
}
|
||||
|
||||
protected function complete(string $systemPrompt, string $userPrompt): string
|
||||
{
|
||||
$response = Http::withHeaders([
|
||||
'x-api-key' => $this->apiKey,
|
||||
'anthropic-version' => '2023-06-01',
|
||||
'content-type' => 'application/json',
|
||||
])->post('https://api.anthropic.com/v1/messages', [
|
||||
'model' => $this->model,
|
||||
'max_tokens' => 2000,
|
||||
'system' => $systemPrompt,
|
||||
'messages' => [
|
||||
[
|
||||
'role' => 'user',
|
||||
'content' => $userPrompt,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
throw new \Exception('AI API error: '.$response->body());
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
return $data['content'][0]['text'] ?? '';
|
||||
}
|
||||
|
||||
protected function buildSystemPrompt(string $type, array $context): string
|
||||
{
|
||||
$prompts = [
|
||||
'email' => 'You are an expert email marketing copywriter specializing in the cannabis industry. You write compelling, conversion-focused email content that complies with cannabis marketing regulations.',
|
||||
];
|
||||
|
||||
$prompt = $prompts[$type] ?? 'You are a helpful AI assistant.';
|
||||
|
||||
if (! empty($context['business'])) {
|
||||
$prompt .= "\n\nBusiness: ".$context['business'];
|
||||
}
|
||||
if (! empty($context['brand'])) {
|
||||
$prompt .= "\nBrand: ".$context['brand'];
|
||||
}
|
||||
if (! empty($context['audience'])) {
|
||||
$prompt .= "\nTarget Audience: ".$context['audience'];
|
||||
}
|
||||
|
||||
return $prompt;
|
||||
}
|
||||
}
|
||||
84
app/Services/Marketing/AnalyticsService.php
Normal file
84
app/Services/Marketing/AnalyticsService.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Marketing;
|
||||
|
||||
use App\Models\Marketing\EmailEvent;
|
||||
use App\Models\Marketing\MarketingEngagement;
|
||||
use App\Models\Marketing\MarketingSession;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AnalyticsService
|
||||
{
|
||||
public function getOverviewStats(int $businessId, ?Carbon $startDate = null, ?Carbon $endDate = null): array
|
||||
{
|
||||
$startDate = $startDate ?? now()->subDays(30);
|
||||
$endDate = $endDate ?? now();
|
||||
|
||||
return [
|
||||
'total_sessions' => MarketingSession::where('business_id', $businessId)
|
||||
->whereBetween('created_at', [$startDate, $endDate])
|
||||
->count(),
|
||||
|
||||
'total_engagements' => MarketingEngagement::where('business_id', $businessId)
|
||||
->whereBetween('created_at', [$startDate, $endDate])
|
||||
->count(),
|
||||
|
||||
'emails_sent' => EmailEvent::where('business_id', $businessId)
|
||||
->where('event_type', 'sent')
|
||||
->whereBetween('created_at', [$startDate, $endDate])
|
||||
->count(),
|
||||
|
||||
'active_automations' => 0, // Placeholder for when automations exist
|
||||
];
|
||||
}
|
||||
|
||||
public function getEmailPerformance(int $businessId, ?Carbon $startDate = null, ?Carbon $endDate = null): array
|
||||
{
|
||||
$startDate = $startDate ?? now()->subDays(30);
|
||||
$endDate = $endDate ?? now();
|
||||
|
||||
$sent = EmailEvent::where('business_id', $businessId)
|
||||
->where('event_type', 'sent')
|
||||
->whereBetween('created_at', [$startDate, $endDate])
|
||||
->count();
|
||||
|
||||
$delivered = EmailEvent::where('business_id', $businessId)
|
||||
->where('event_type', 'delivered')
|
||||
->whereBetween('created_at', [$startDate, $endDate])
|
||||
->count();
|
||||
|
||||
$opened = EmailEvent::where('business_id', $businessId)
|
||||
->where('event_type', 'opened')
|
||||
->whereBetween('created_at', [$startDate, $endDate])
|
||||
->count();
|
||||
|
||||
$clicked = EmailEvent::where('business_id', $businessId)
|
||||
->where('event_type', 'clicked')
|
||||
->whereBetween('created_at', [$startDate, $endDate])
|
||||
->count();
|
||||
|
||||
return [
|
||||
'sent' => $sent,
|
||||
'delivered' => $delivered,
|
||||
'opened' => $opened,
|
||||
'clicked' => $clicked,
|
||||
'delivery_rate' => $sent > 0 ? round(($delivered / $sent) * 100, 2) : 0,
|
||||
'open_rate' => $delivered > 0 ? round(($opened / $delivered) * 100, 2) : 0,
|
||||
'click_rate' => $opened > 0 ? round(($clicked / $opened) * 100, 2) : 0,
|
||||
];
|
||||
}
|
||||
|
||||
public function getEngagementByType(int $businessId, ?Carbon $startDate = null, ?Carbon $endDate = null): array
|
||||
{
|
||||
$startDate = $startDate ?? now()->subDays(30);
|
||||
$endDate = $endDate ?? now();
|
||||
|
||||
return MarketingEngagement::where('business_id', $businessId)
|
||||
->whereBetween('created_at', [$startDate, $endDate])
|
||||
->select('engagement_type', DB::raw('count(*) as count'))
|
||||
->groupBy('engagement_type')
|
||||
->pluck('count', 'engagement_type')
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
442
app/Services/Marketing/BroadcastService.php
Normal file
442
app/Services/Marketing/BroadcastService.php
Normal file
@@ -0,0 +1,442 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Marketing;
|
||||
|
||||
use App\Jobs\Marketing\SendBroadcastJob;
|
||||
use App\Jobs\Marketing\SendBroadcastMessageJob;
|
||||
use App\Models\Broadcast;
|
||||
use App\Models\BroadcastEvent;
|
||||
use App\Models\BroadcastRecipient;
|
||||
use App\Models\MarketingAudience;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class BroadcastService
|
||||
{
|
||||
protected TemplateRenderingService $templateService;
|
||||
|
||||
public function __construct(TemplateRenderingService $templateService)
|
||||
{
|
||||
$this->templateService = $templateService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate recipients for broadcast
|
||||
*/
|
||||
public function calculateRecipients(Broadcast $broadcast): Collection
|
||||
{
|
||||
$query = User::query()
|
||||
->where('business_id', $broadcast->business_id)
|
||||
->where('role', 'buyer')
|
||||
->where('is_active', true);
|
||||
|
||||
// Include all buyers
|
||||
if ($broadcast->include_all) {
|
||||
// Apply exclusions if any
|
||||
if ($broadcast->exclude_audience_ids) {
|
||||
$excludedUserIds = MarketingAudience::whereIn('id', $broadcast->exclude_audience_ids)
|
||||
->get()
|
||||
->flatMap(fn ($audience) => $audience->members->pluck('id'))
|
||||
->unique();
|
||||
|
||||
if ($excludedUserIds->isNotEmpty()) {
|
||||
$query->whereNotIn('id', $excludedUserIds);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Include specific audiences
|
||||
elseif ($broadcast->audience_ids) {
|
||||
$userIds = MarketingAudience::whereIn('id', $broadcast->audience_ids)
|
||||
->get()
|
||||
->flatMap(fn ($audience) => $audience->members->pluck('id'))
|
||||
->unique();
|
||||
|
||||
$query->whereIn('id', $userIds);
|
||||
}
|
||||
// Apply custom segment rules
|
||||
elseif ($broadcast->segment_rules) {
|
||||
foreach ($broadcast->segment_rules as $rule) {
|
||||
$this->applySegmentRule($query, $rule);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by channel preference (if user has unsubscribed)
|
||||
$query->where(function ($q) use ($broadcast) {
|
||||
$q->whereNull('unsubscribed_from_'.$broadcast->channel)
|
||||
->orWhere('unsubscribed_from_'.$broadcast->channel, false);
|
||||
});
|
||||
|
||||
return $query->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply segment rule to query
|
||||
*/
|
||||
protected function applySegmentRule($query, array $rule): void
|
||||
{
|
||||
$field = $rule['field'];
|
||||
$operator = $rule['operator'];
|
||||
$value = $rule['value'];
|
||||
|
||||
switch ($operator) {
|
||||
case '=':
|
||||
$query->where($field, $value);
|
||||
break;
|
||||
case '!=':
|
||||
$query->where($field, '!=', $value);
|
||||
break;
|
||||
case '>':
|
||||
$query->where($field, '>', $value);
|
||||
break;
|
||||
case '<':
|
||||
$query->where($field, '<', $value);
|
||||
break;
|
||||
case 'contains':
|
||||
$query->where($field, 'LIKE', "%{$value}%");
|
||||
break;
|
||||
case 'in':
|
||||
$query->whereIn($field, (array) $value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare broadcast for sending
|
||||
*/
|
||||
public function prepareBroadcast(Broadcast $broadcast): int
|
||||
{
|
||||
DB::beginTransaction();
|
||||
|
||||
try {
|
||||
// Calculate recipients
|
||||
$recipients = $this->calculateRecipients($broadcast);
|
||||
|
||||
// Clear existing recipients if re-preparing
|
||||
$broadcast->recipients()->delete();
|
||||
|
||||
// Create recipient records
|
||||
$recipientData = $recipients->map(fn ($user) => [
|
||||
'broadcast_id' => $broadcast->id,
|
||||
'user_id' => $user->id,
|
||||
'status' => 'pending',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
])->toArray();
|
||||
|
||||
BroadcastRecipient::insert($recipientData);
|
||||
|
||||
// Update broadcast stats
|
||||
$broadcast->update([
|
||||
'total_recipients' => $recipients->count(),
|
||||
'status' => $broadcast->type === 'scheduled' ? 'scheduled' : 'draft',
|
||||
]);
|
||||
|
||||
DB::commit();
|
||||
|
||||
return $recipients->count();
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send broadcast immediately
|
||||
*/
|
||||
public function sendBroadcast(Broadcast $broadcast): void
|
||||
{
|
||||
if (! $broadcast->canBeSent()) {
|
||||
throw new \Exception("Broadcast cannot be sent in current status: {$broadcast->status}");
|
||||
}
|
||||
|
||||
// Update status
|
||||
$broadcast->update([
|
||||
'status' => 'sending',
|
||||
'started_sending_at' => now(),
|
||||
]);
|
||||
|
||||
// Dispatch main sending job
|
||||
SendBroadcastJob::dispatch($broadcast);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process broadcast sending (called by queue job)
|
||||
*/
|
||||
public function processBroadcastSending(Broadcast $broadcast): void
|
||||
{
|
||||
$recipients = $broadcast->recipients()
|
||||
->where('status', 'pending')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
$rateLimit = $broadcast->send_rate_limit ?? 100; // Default 100/min
|
||||
$delayPerMessage = 60 / $rateLimit; // Seconds between messages
|
||||
|
||||
foreach ($recipients as $index => $recipient) {
|
||||
$delay = $index * $delayPerMessage;
|
||||
|
||||
SendBroadcastMessageJob::dispatch($broadcast, $recipient)
|
||||
->delay(now()->addSeconds($delay));
|
||||
|
||||
$recipient->markAsQueued();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to individual recipient
|
||||
*/
|
||||
public function sendToRecipient(Broadcast $broadcast, BroadcastRecipient $recipient): void
|
||||
{
|
||||
try {
|
||||
$user = $recipient->user;
|
||||
|
||||
// Render content with variables
|
||||
$content = $this->renderContent($broadcast, $user);
|
||||
|
||||
// Send via appropriate channel
|
||||
$messageId = match ($broadcast->channel) {
|
||||
'email' => $this->sendEmail($user, $content),
|
||||
'sms' => $this->sendSMS($user, $content),
|
||||
'push' => $this->sendPush($user, $content),
|
||||
'multi' => $this->sendMultiChannel($user, $content),
|
||||
};
|
||||
|
||||
// Mark as sent
|
||||
$recipient->markAsSent($messageId);
|
||||
|
||||
// Update broadcast stats
|
||||
$broadcast->increment('total_sent');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$recipient->markAsFailed($e->getMessage(), $e->getCode());
|
||||
$broadcast->increment('total_failed');
|
||||
|
||||
\Log::error('Broadcast send failed', [
|
||||
'broadcast_id' => $broadcast->id,
|
||||
'recipient_id' => $recipient->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render content with variables
|
||||
*/
|
||||
protected function renderContent(Broadcast $broadcast, User $user): array
|
||||
{
|
||||
$context = [
|
||||
'customer' => $user,
|
||||
'business' => $broadcast->business,
|
||||
'unsubscribe_url' => route('unsubscribe', ['user' => $user->id, 'channel' => $broadcast->channel]),
|
||||
];
|
||||
|
||||
if ($broadcast->template) {
|
||||
return $this->templateService->render($broadcast->template, $context);
|
||||
}
|
||||
|
||||
// Use broadcast content directly
|
||||
return [
|
||||
'subject' => $this->templateService->replaceVariables($broadcast->subject ?? '', $context),
|
||||
'body' => $this->templateService->replaceVariables($broadcast->content ?? '', $context),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Send email
|
||||
*/
|
||||
protected function sendEmail(User $user, array $content): string
|
||||
{
|
||||
// Integration with your email service (e.g., SendGrid, SES, Mailgun)
|
||||
// This is a placeholder - implement based on your email provider
|
||||
|
||||
\Mail::to($user->email)->send(
|
||||
new \App\Mail\BroadcastEmail($content['subject'], $content['body'])
|
||||
);
|
||||
|
||||
return 'email-'.uniqid();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send SMS
|
||||
*/
|
||||
protected function sendSMS(User $user, array $content): string
|
||||
{
|
||||
// Integration with SMS service (e.g., Twilio, SNS)
|
||||
// Placeholder implementation
|
||||
|
||||
return 'sms-'.uniqid();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send push notification
|
||||
*/
|
||||
protected function sendPush(User $user, array $content): string
|
||||
{
|
||||
// Integration with push service (e.g., FCM, OneSignal)
|
||||
// Placeholder implementation
|
||||
|
||||
return 'push-'.uniqid();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send multi-channel
|
||||
*/
|
||||
protected function sendMultiChannel(User $user, array $content): string
|
||||
{
|
||||
// Send through multiple channels
|
||||
$messageIds = [];
|
||||
|
||||
try {
|
||||
$messageIds['email'] = $this->sendEmail($user, $content);
|
||||
} catch (\Exception $e) {
|
||||
\Log::warning('Multi-channel email failed', ['user_id' => $user->id]);
|
||||
}
|
||||
|
||||
try {
|
||||
$messageIds['sms'] = $this->sendSMS($user, $content);
|
||||
} catch (\Exception $e) {
|
||||
\Log::warning('Multi-channel SMS failed', ['user_id' => $user->id]);
|
||||
}
|
||||
|
||||
return json_encode($messageIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check broadcast completion
|
||||
*/
|
||||
public function checkBroadcastCompletion(Broadcast $broadcast): void
|
||||
{
|
||||
$pendingCount = $broadcast->recipients()
|
||||
->whereIn('status', ['pending', 'queued', 'sending'])
|
||||
->count();
|
||||
|
||||
if ($pendingCount === 0) {
|
||||
$broadcast->update([
|
||||
'status' => 'sent',
|
||||
'finished_sending_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel broadcast
|
||||
*/
|
||||
public function cancelBroadcast(Broadcast $broadcast): void
|
||||
{
|
||||
if (! $broadcast->canBeCancelled()) {
|
||||
throw new \Exception('Broadcast cannot be cancelled');
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($broadcast) {
|
||||
// Update pending recipients
|
||||
$broadcast->recipients()
|
||||
->whereIn('status', ['pending', 'queued'])
|
||||
->update(['status' => 'skipped']);
|
||||
|
||||
// Update broadcast
|
||||
$broadcast->update([
|
||||
'status' => 'cancelled',
|
||||
'finished_sending_at' => now(),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause broadcast
|
||||
*/
|
||||
public function pauseBroadcast(Broadcast $broadcast): void
|
||||
{
|
||||
if ($broadcast->status !== 'sending') {
|
||||
throw new \Exception('Only sending broadcasts can be paused');
|
||||
}
|
||||
|
||||
$broadcast->update(['status' => 'paused']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume broadcast
|
||||
*/
|
||||
public function resumeBroadcast(Broadcast $broadcast): void
|
||||
{
|
||||
if ($broadcast->status !== 'paused') {
|
||||
throw new \Exception('Only paused broadcasts can be resumed');
|
||||
}
|
||||
|
||||
$broadcast->update(['status' => 'sending']);
|
||||
|
||||
// Re-queue pending recipients
|
||||
$this->processBroadcastSending($broadcast);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track event (open, click, etc)
|
||||
*/
|
||||
public function trackEvent(Broadcast $broadcast, User $user, string $event, array $data = []): void
|
||||
{
|
||||
BroadcastEvent::create([
|
||||
'broadcast_id' => $broadcast->id,
|
||||
'user_id' => $user->id,
|
||||
'event' => $event,
|
||||
'link_url' => $data['url'] ?? null,
|
||||
'user_agent' => $data['user_agent'] ?? request()->userAgent(),
|
||||
'ip_address' => $data['ip'] ?? request()->ip(),
|
||||
'device_type' => $this->detectDeviceType($data['user_agent'] ?? null),
|
||||
'metadata' => $data['metadata'] ?? null,
|
||||
]);
|
||||
|
||||
// Update broadcast stats
|
||||
match ($event) {
|
||||
'opened' => $broadcast->increment('total_opened'),
|
||||
'clicked' => $broadcast->increment('total_clicked'),
|
||||
'unsubscribed' => $broadcast->increment('total_unsubscribed'),
|
||||
'delivered' => $broadcast->increment('total_delivered'),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect device type from user agent
|
||||
*/
|
||||
protected function detectDeviceType(?string $userAgent): ?string
|
||||
{
|
||||
if (! $userAgent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('/mobile/i', $userAgent)) {
|
||||
return 'mobile';
|
||||
}
|
||||
|
||||
if (preg_match('/tablet/i', $userAgent)) {
|
||||
return 'tablet';
|
||||
}
|
||||
|
||||
return 'desktop';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get broadcast statistics
|
||||
*/
|
||||
public function getStatistics(Broadcast $broadcast): array
|
||||
{
|
||||
return [
|
||||
'total_recipients' => $broadcast->total_recipients,
|
||||
'total_sent' => $broadcast->total_sent,
|
||||
'total_delivered' => $broadcast->total_delivered,
|
||||
'total_failed' => $broadcast->total_failed,
|
||||
'total_opened' => $broadcast->total_opened,
|
||||
'total_clicked' => $broadcast->total_clicked,
|
||||
'total_unsubscribed' => $broadcast->total_unsubscribed,
|
||||
'open_rate' => $broadcast->getOpenRate(),
|
||||
'click_rate' => $broadcast->getClickRate(),
|
||||
'delivery_rate' => $broadcast->getDeliveryRate(),
|
||||
'status_breakdown' => $broadcast->recipients()
|
||||
->select('status', DB::raw('count(*) as count'))
|
||||
->groupBy('status')
|
||||
->pluck('count', 'status')
|
||||
->toArray(),
|
||||
];
|
||||
}
|
||||
}
|
||||
130
app/Services/Marketing/MergeTagService.php
Normal file
130
app/Services/Marketing/MergeTagService.php
Normal file
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Marketing;
|
||||
|
||||
use App\Models\Brand;
|
||||
use App\Models\Business;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class MergeTagService
|
||||
{
|
||||
public function getAvailableTags(?Brand $brand = null): array
|
||||
{
|
||||
$tags = [
|
||||
'{{buyer_name}}' => 'Buyer full name',
|
||||
'{{buyer_first_name}}' => 'Buyer first name',
|
||||
'{{buyer_last_name}}' => 'Buyer last name',
|
||||
'{{buyer_email}}' => 'Buyer email address',
|
||||
'{{buyer_phone}}' => 'Buyer phone number',
|
||||
'{{business_name}}' => 'Your business name',
|
||||
'{{business_email}}' => 'Business email',
|
||||
'{{business_phone}}' => 'Business phone',
|
||||
'{{business_address}}' => 'Business address',
|
||||
'{{business_website}}' => 'Business website',
|
||||
'{{order_number}}' => 'Order number',
|
||||
'{{order_total}}' => 'Order total amount',
|
||||
'{{order_date}}' => 'Order date',
|
||||
'{{unsubscribe_link}}' => 'Unsubscribe URL',
|
||||
'{{view_in_browser}}' => 'View in browser URL',
|
||||
'{{current_year}}' => 'Current year',
|
||||
'{{current_date}}' => 'Current date',
|
||||
];
|
||||
|
||||
if ($brand) {
|
||||
$tags = array_merge($tags, [
|
||||
'{{brand_name}}' => 'Brand name',
|
||||
'{{brand_logo}}' => 'Brand logo URL',
|
||||
'{{brand_email}}' => 'Brand email',
|
||||
'{{brand_phone}}' => 'Brand phone',
|
||||
'{{brand_address}}' => 'Brand address',
|
||||
'{{brand_primary_color}}' => 'Brand primary color',
|
||||
'{{brand_secondary_color}}' => 'Brand secondary color',
|
||||
]);
|
||||
}
|
||||
|
||||
return $tags;
|
||||
}
|
||||
|
||||
public function replace(
|
||||
string $content,
|
||||
array $data = [],
|
||||
?Business $business = null,
|
||||
?Brand $brand = null
|
||||
): string {
|
||||
$business = $business ?? currentBusiness();
|
||||
$replacements = [];
|
||||
|
||||
if (isset($data['buyer'])) {
|
||||
$buyer = $data['buyer'];
|
||||
$replacements['{{buyer_name}}'] = $buyer->name ?? '';
|
||||
$replacements['{{buyer_first_name}}'] = $buyer->first_name ?? Str::before($buyer->name ?? '', ' ');
|
||||
$replacements['{{buyer_last_name}}'] = $buyer->last_name ?? Str::after($buyer->name ?? '', ' ');
|
||||
$replacements['{{buyer_email}}'] = $buyer->email ?? '';
|
||||
$replacements['{{buyer_phone}}'] = $buyer->phone ?? '';
|
||||
}
|
||||
|
||||
if ($business) {
|
||||
$replacements['{{business_name}}'] = $business->name;
|
||||
$replacements['{{business_email}}'] = $business->email ?? '';
|
||||
$replacements['{{business_phone}}'] = $business->phone ?? '';
|
||||
$replacements['{{business_address}}'] = $business->address ?? '';
|
||||
$replacements['{{business_website}}'] = $business->website ?? '';
|
||||
}
|
||||
|
||||
if ($brand) {
|
||||
$brandKit = $brand->defaultBrandKit;
|
||||
|
||||
$replacements['{{brand_name}}'] = $brand->name;
|
||||
$replacements['{{brand_logo}}'] = $brandKit?->getLogoUrl() ?? '';
|
||||
$replacements['{{brand_email}}'] = $brand->email ?? '';
|
||||
$replacements['{{brand_phone}}'] = $brand->phone ?? '';
|
||||
$replacements['{{brand_address}}'] = $brand->address ?? '';
|
||||
$replacements['{{brand_primary_color}}'] = $brandKit?->getPrimaryColor() ?? '#000000';
|
||||
$replacements['{{brand_secondary_color}}'] = $brandKit?->getAllColors()['secondary'] ?? '#666666';
|
||||
}
|
||||
|
||||
if (isset($data['order'])) {
|
||||
$order = $data['order'];
|
||||
$replacements['{{order_number}}'] = $order->order_number ?? '';
|
||||
$replacements['{{order_total}}'] = $order->total ?? '';
|
||||
$replacements['{{order_date}}'] = $order->created_at?->format('M d, Y') ?? '';
|
||||
}
|
||||
|
||||
$replacements['{{unsubscribe_link}}'] = $data['unsubscribe_link'] ?? '#';
|
||||
$replacements['{{view_in_browser}}'] = $data['view_in_browser_link'] ?? '#';
|
||||
$replacements['{{current_year}}'] = now()->year;
|
||||
$replacements['{{current_date}}'] = now()->format('F j, Y');
|
||||
|
||||
foreach ($data as $key => $value) {
|
||||
if (is_scalar($value)) {
|
||||
$replacements["{{{$key}}}"] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return str_replace(
|
||||
array_keys($replacements),
|
||||
array_values($replacements),
|
||||
$content
|
||||
);
|
||||
}
|
||||
|
||||
public function extractTags(string $content): array
|
||||
{
|
||||
preg_match_all('/\{\{([^}]+)\}\}/', $content, $matches);
|
||||
|
||||
return array_unique($matches[0]);
|
||||
}
|
||||
|
||||
public function validate(string $content, ?Brand $brand = null): array
|
||||
{
|
||||
$usedTags = $this->extractTags($content);
|
||||
$availableTags = array_keys($this->getAvailableTags($brand));
|
||||
|
||||
$invalidTags = array_diff($usedTags, $availableTags);
|
||||
|
||||
return [
|
||||
'valid' => empty($invalidTags),
|
||||
'invalid_tags' => array_values($invalidTags),
|
||||
];
|
||||
}
|
||||
}
|
||||
89
app/Services/Marketing/MjmlService.php
Normal file
89
app/Services/Marketing/MjmlService.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Marketing;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class MjmlService
|
||||
{
|
||||
public function render(string $mjml): string
|
||||
{
|
||||
if (config('services.mjml.api_key')) {
|
||||
return $this->renderViaApi($mjml);
|
||||
}
|
||||
|
||||
return $this->renderLocally($mjml);
|
||||
}
|
||||
|
||||
protected function renderViaApi(string $mjml): string
|
||||
{
|
||||
$response = Http::withBasicAuth(
|
||||
config('services.mjml.app_id'),
|
||||
config('services.mjml.api_key')
|
||||
)->post('https://api.mjml.io/v1/render', [
|
||||
'mjml' => $mjml,
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
return $response->json('html');
|
||||
}
|
||||
|
||||
throw new \Exception('MJML API error: '.$response->body());
|
||||
}
|
||||
|
||||
protected function renderLocally(string $mjml): string
|
||||
{
|
||||
$tempMjml = tempnam(sys_get_temp_dir(), 'mjml_');
|
||||
file_put_contents($tempMjml, $mjml);
|
||||
|
||||
$tempHtml = tempnam(sys_get_temp_dir(), 'html_');
|
||||
exec("npx mjml {$tempMjml} -o {$tempHtml}", $output, $returnCode);
|
||||
|
||||
if ($returnCode !== 0) {
|
||||
throw new \Exception('MJML rendering failed');
|
||||
}
|
||||
|
||||
$html = file_get_contents($tempHtml);
|
||||
|
||||
unlink($tempMjml);
|
||||
unlink($tempHtml);
|
||||
|
||||
return $this->inlineCss($html);
|
||||
}
|
||||
|
||||
public function htmlToMjml(string $html): string
|
||||
{
|
||||
return <<<MJML
|
||||
<mjml>
|
||||
<mj-body>
|
||||
<mj-section>
|
||||
<mj-column>
|
||||
<mj-text>
|
||||
{$html}
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</mj-body>
|
||||
</mjml>
|
||||
MJML;
|
||||
}
|
||||
|
||||
protected function inlineCss(string $html): string
|
||||
{
|
||||
return $html;
|
||||
}
|
||||
|
||||
public function validate(string $mjml): array
|
||||
{
|
||||
try {
|
||||
$this->render($mjml);
|
||||
|
||||
return ['valid' => true, 'errors' => []];
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
'valid' => false,
|
||||
'errors' => [$e->getMessage()],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
227
app/Services/Marketing/TemplateService.php
Normal file
227
app/Services/Marketing/TemplateService.php
Normal file
@@ -0,0 +1,227 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Marketing;
|
||||
|
||||
use App\Models\Brand;
|
||||
use App\Models\Marketing\Template;
|
||||
use App\Models\Marketing\TemplateAnalytics;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class TemplateService
|
||||
{
|
||||
public function __construct(
|
||||
protected MjmlService $mjmlService,
|
||||
protected MergeTagService $mergeTagService
|
||||
) {}
|
||||
|
||||
public function create(array $data): Template
|
||||
{
|
||||
if (! empty($data['mjml_content'])) {
|
||||
$data['html_content'] = $this->mjmlService->render($data['mjml_content']);
|
||||
}
|
||||
|
||||
if (! empty($data['html_content'])) {
|
||||
$data['plain_text'] = strip_tags($data['html_content']);
|
||||
}
|
||||
|
||||
if (empty($data['thumbnail']) && ! empty($data['html_content'])) {
|
||||
$data['thumbnail'] = $this->generateThumbnail($data['html_content']);
|
||||
}
|
||||
|
||||
$template = Template::create([
|
||||
'business_id' => currentBusiness()->id,
|
||||
'created_by' => auth()->id(),
|
||||
...$data,
|
||||
]);
|
||||
|
||||
$template->createVersion('Initial version');
|
||||
$this->initializeAnalytics($template);
|
||||
|
||||
return $template;
|
||||
}
|
||||
|
||||
public function update(Template $template, array $data): Template
|
||||
{
|
||||
if ($template->is_system_template) {
|
||||
throw new \Exception('Cannot edit system templates');
|
||||
}
|
||||
|
||||
if (! empty($data['mjml_content'])) {
|
||||
$data['html_content'] = $this->mjmlService->render($data['mjml_content']);
|
||||
}
|
||||
|
||||
if (! empty($data['html_content'])) {
|
||||
$data['plain_text'] = strip_tags($data['html_content']);
|
||||
}
|
||||
|
||||
$data['version'] = $template->version + 1;
|
||||
$data['updated_by'] = auth()->id();
|
||||
|
||||
$template->createVersion($data['change_notes'] ?? 'Updated template');
|
||||
$template->update($data);
|
||||
|
||||
return $template->fresh();
|
||||
}
|
||||
|
||||
public function duplicate(Template $template, string $newName, ?int $brandId = null): Template
|
||||
{
|
||||
$duplicate = $template->duplicate($newName);
|
||||
|
||||
if ($brandId) {
|
||||
$this->addToBrand($duplicate, $brandId);
|
||||
}
|
||||
|
||||
return $duplicate;
|
||||
}
|
||||
|
||||
public function delete(Template $template): bool
|
||||
{
|
||||
if (! $template->canBeDeleted()) {
|
||||
throw new \Exception('Template is in use and cannot be deleted');
|
||||
}
|
||||
|
||||
return $template->delete();
|
||||
}
|
||||
|
||||
public function addToBrand(Template $template, int $brandId): void
|
||||
{
|
||||
$brand = Brand::findOrFail($brandId);
|
||||
|
||||
if ($template->brands()->where('brand_id', $brandId)->exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$template->brands()->attach($brandId, [
|
||||
'added_by' => auth()->id(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function removeFromBrand(Template $template, int $brandId): void
|
||||
{
|
||||
$template->brands()->detach($brandId);
|
||||
}
|
||||
|
||||
public function toggleFavorite(Template $template, int $brandId): void
|
||||
{
|
||||
$pivot = DB::table('brand_templates')
|
||||
->where('brand_id', $brandId)
|
||||
->where('template_id', $template->id)
|
||||
->first();
|
||||
|
||||
if ($pivot) {
|
||||
DB::table('brand_templates')
|
||||
->where('id', $pivot->id)
|
||||
->update(['is_favorite' => ! $pivot->is_favorite]);
|
||||
}
|
||||
}
|
||||
|
||||
public function importFromHtml(string $html, array $metadata = []): Template
|
||||
{
|
||||
$mjml = $this->mjmlService->htmlToMjml($html);
|
||||
$designJson = $this->htmlToGrapesJsDesign($html);
|
||||
|
||||
return $this->create([
|
||||
'name' => $metadata['name'] ?? 'Imported Template',
|
||||
'description' => $metadata['description'] ?? null,
|
||||
'category_id' => $metadata['category_id'] ?? null,
|
||||
'tags' => $metadata['tags'] ?? [],
|
||||
'design_json' => $designJson,
|
||||
'mjml_content' => $mjml,
|
||||
'html_content' => $html,
|
||||
'template_type' => 'email',
|
||||
]);
|
||||
}
|
||||
|
||||
public function exportToHtml(Template $template): string
|
||||
{
|
||||
return $template->html_content;
|
||||
}
|
||||
|
||||
public function exportToMjml(Template $template): string
|
||||
{
|
||||
return $template->mjml_content ?? $this->mjmlService->htmlToMjml($template->html_content);
|
||||
}
|
||||
|
||||
public function exportAsZip(Template $template): string
|
||||
{
|
||||
$zip = new \ZipArchive;
|
||||
$filename = storage_path('app/temp/'.$template->slug.'.zip');
|
||||
|
||||
if ($zip->open($filename, \ZipArchive::CREATE) !== true) {
|
||||
throw new \Exception('Cannot create ZIP file');
|
||||
}
|
||||
|
||||
$zip->addFromString('template.html', $template->html_content);
|
||||
|
||||
if ($template->mjml_content) {
|
||||
$zip->addFromString('template.mjml', $template->mjml_content);
|
||||
}
|
||||
|
||||
$zip->addFromString('design.json', json_encode($template->design_json, JSON_PRETTY_PRINT));
|
||||
|
||||
$metadata = [
|
||||
'name' => $template->name,
|
||||
'description' => $template->description,
|
||||
'category' => $template->category?->name,
|
||||
'tags' => $template->tags,
|
||||
'version' => $template->version,
|
||||
'exported_at' => now()->toIso8601String(),
|
||||
];
|
||||
$zip->addFromString('metadata.json', json_encode($metadata, JSON_PRETTY_PRINT));
|
||||
|
||||
$zip->close();
|
||||
|
||||
return $filename;
|
||||
}
|
||||
|
||||
public function render(Template $template, array $data = [], ?Brand $brand = null): string
|
||||
{
|
||||
return $this->mergeTagService->replace(
|
||||
$template->html_content,
|
||||
$data,
|
||||
currentBusiness(),
|
||||
$brand
|
||||
);
|
||||
}
|
||||
|
||||
protected function initializeAnalytics(Template $template): void
|
||||
{
|
||||
TemplateAnalytics::create([
|
||||
'template_id' => $template->id,
|
||||
'business_id' => $template->business_id,
|
||||
'brand_id' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function generateThumbnail(string $html): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function htmlToGrapesJsDesign(string $html): array
|
||||
{
|
||||
return [
|
||||
'assets' => [],
|
||||
'styles' => [],
|
||||
'pages' => [
|
||||
[
|
||||
'frames' => [
|
||||
[
|
||||
'component' => [
|
||||
'type' => 'wrapper',
|
||||
'components' => [
|
||||
[
|
||||
'type' => 'text',
|
||||
'content' => $html,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
450
app/Services/PermissionService.php
Normal file
450
app/Services/PermissionService.php
Normal 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 ?? [];
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
58
app/Traits/HasHashid.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
37
check-all-sql-files.php
Normal file
37
check-all-sql-files.php
Normal 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";
|
||||
}
|
||||
187
check-and-import-missing-brand-images.php
Normal file
187
check-and-import-missing-brand-images.php
Normal 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
27
check-brand-active.php
Normal 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
15
check-brand-names.php
Normal 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
36
check-brand.php
Normal 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
20
check-categories.php
Normal 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
13
check-columns.php
Normal 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
55
check-nuvata-images.php
Normal 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
64
check-nuvata-products.php
Normal 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
56
check-nuvata-state.php
Normal 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
22
check-product-columns.php
Normal 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";
|
||||
}
|
||||
35
check-product-images-schema.php
Normal file
35
check-product-images-schema.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?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 "=== PRODUCT_IMAGES TABLE SCHEMA ===\n\n";
|
||||
$stmt = $conn->query('DESCRIBE product_images');
|
||||
$columns = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
foreach ($columns as $col) {
|
||||
echo " {$col['Field']} ({$col['Type']})\n";
|
||||
}
|
||||
|
||||
echo "\n\n=== SAMPLE ROW ===\n";
|
||||
$stmt = $conn->query('SELECT * FROM product_images LIMIT 1');
|
||||
$sample = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($sample) {
|
||||
foreach ($sample as $key => $value) {
|
||||
if (is_string($value) && strlen($value) > 100) {
|
||||
$value = substr($value, 0, 100).'... ['.strlen($value).' bytes]';
|
||||
}
|
||||
echo " $key: $value\n";
|
||||
}
|
||||
}
|
||||
|
||||
} catch (PDOException $e) {
|
||||
echo 'ERROR: '.$e->getMessage()."\n";
|
||||
}
|
||||
35
check_all_brands.php
Normal file
35
check_all_brands.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
require __DIR__.'/vendor/autoload.php';
|
||||
|
||||
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||
$app->make('Illuminate\Contracts\Console\Kernel')->bootstrap();
|
||||
|
||||
use App\Models\Brand;
|
||||
|
||||
echo "All brands in the database:\n\n";
|
||||
|
||||
$brands = Brand::with('business')->get();
|
||||
|
||||
if ($brands->isEmpty()) {
|
||||
echo "No brands found in database\n";
|
||||
} else {
|
||||
foreach ($brands as $brand) {
|
||||
echo "Brand ID: {$brand->id}\n";
|
||||
echo "Brand Name: {$brand->name}\n";
|
||||
echo "Business ID: {$brand->business_id}\n";
|
||||
echo 'Business Name: '.($brand->business ? $brand->business->name : 'N/A')."\n";
|
||||
echo 'Active: '.($brand->is_active ? 'Yes' : 'No')."\n";
|
||||
echo "---\n";
|
||||
}
|
||||
}
|
||||
|
||||
echo "\nCannabrands business (ID: 5) brands:\n";
|
||||
$cannabrandsBrands = Brand::where('business_id', 5)->get();
|
||||
if ($cannabrandsBrands->isEmpty()) {
|
||||
echo "No brands found for Cannabrands\n";
|
||||
} else {
|
||||
foreach ($cannabrandsBrands as $brand) {
|
||||
echo "- {$brand->name} (ID: {$brand->id})\n";
|
||||
}
|
||||
}
|
||||
26
check_cannabrands.php
Normal file
26
check_cannabrands.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
require __DIR__.'/vendor/autoload.php';
|
||||
|
||||
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||
$app->make('Illuminate\Contracts\Console\Kernel')->bootstrap();
|
||||
|
||||
$businesses = App\Models\Business::where('name', 'LIKE', '%cannabrand%')
|
||||
->orWhere('slug', 'LIKE', '%cannabrand%')
|
||||
->get();
|
||||
|
||||
if ($businesses->count() > 0) {
|
||||
echo "Found {$businesses->count()} business(es) matching Cannabrands:\n";
|
||||
foreach ($businesses as $business) {
|
||||
echo "ID: {$business->id} | Name: {$business->name} | Slug: {$business->slug} | Type: {$business->type}\n";
|
||||
}
|
||||
} else {
|
||||
echo "No Cannabrands business found in database\n";
|
||||
|
||||
// Also list all businesses
|
||||
echo "\nAll businesses in database:\n";
|
||||
$allBusinesses = App\Models\Business::all();
|
||||
foreach ($allBusinesses as $business) {
|
||||
echo "ID: {$business->id} | Name: {$business->name} | Slug: {$business->slug}\n";
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user