Compare commits
60 Commits
feat/omnic
...
fix/route-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
937a1b5024 | ||
|
|
11beea936b | ||
|
|
f05a35b8b1 | ||
|
|
ebeb340769 | ||
|
|
2f06a35501 | ||
|
|
70b3be142b | ||
|
|
1632f2301e | ||
|
|
c7f74aba08 | ||
|
|
f0624dd194 | ||
|
|
6edc6f2468 | ||
|
|
c6ada94a56 | ||
|
|
ec3f6338b5 | ||
|
|
3fbd05dff7 | ||
|
|
9720ca6574 | ||
|
|
7904e5764c | ||
|
|
812238945b | ||
|
|
f685f1e248 | ||
|
|
aaddd75963 | ||
|
|
7d62d3cd7e | ||
|
|
9832d8e17c | ||
|
|
89fbd336ba | ||
|
|
61d5c2d456 | ||
|
|
fe3916e6b7 | ||
|
|
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 |
@@ -19,7 +19,13 @@
|
||||
"Bash(grep:*)",
|
||||
"Bash(sed:*)",
|
||||
"Bash(php artisan:*)",
|
||||
"Bash(php check_blade.php:*)"
|
||||
"Bash(php check_blade.php:*)",
|
||||
"Bash(./vendor/bin/pint:*)",
|
||||
"Bash(git worktree add:*)",
|
||||
"Bash(if [ -d \"../hub-worktrees/feature/pr-8-analytics-dashboard\" ])",
|
||||
"Bash(then echo \"EXISTS\")",
|
||||
"Bash(else echo \"NOT_EXISTS\")",
|
||||
"Bash(fi)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
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.
|
||||
377
PERMISSIONS_ARCHITECTURE.md
Normal file
377
PERMISSIONS_ARCHITECTURE.md
Normal file
@@ -0,0 +1,377 @@
|
||||
# PERMISSION SYSTEM INVESTIGATION REPORT
|
||||
|
||||
## 1. BUSINESS SCOPING MECHANISM
|
||||
|
||||
### a) No Middleware Found
|
||||
**FACT:** No middleware files with "business" or "tenant" in their names exist in `app/Http/Middleware/`
|
||||
|
||||
### b) Route-Level Business Scoping
|
||||
**LOCATION:** [routes/seller.php:8-16](routes/seller.php#L8-L16)
|
||||
|
||||
**FACT:** Business scoping is enforced at the route binding level via custom route model binding:
|
||||
|
||||
```php
|
||||
Route::bind('business', function (string $value) {
|
||||
$business = \App\Models\Business::where('slug', $value)->firstOrFail();
|
||||
|
||||
// Verify user has access to this business
|
||||
if (! auth()->check() || ! auth()->user()->businesses->contains($business->id)) {
|
||||
abort(403, 'You do not have access to this business.');
|
||||
}
|
||||
|
||||
return $business;
|
||||
});
|
||||
```
|
||||
|
||||
**RESULT:** When a route contains `{business}` parameter, this binding automatically:
|
||||
- Resolves business by slug
|
||||
- Verifies authenticated user belongs to that business
|
||||
- Returns 403 if user doesn't have access
|
||||
|
||||
### c) Business Model - users() Relationship
|
||||
**LOCATION:** [app/Models/Business.php:250-256](app/Models/Business.php#L250-L256)
|
||||
|
||||
**FACT:** Uses `business_user` pivot table with custom permissions:
|
||||
|
||||
```php
|
||||
public function users(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'business_user')
|
||||
->using(BusinessUser::class)
|
||||
->withPivot('contact_type', 'is_primary', 'permissions')
|
||||
->withTimestamps();
|
||||
}
|
||||
```
|
||||
|
||||
**PIVOT COLUMNS:**
|
||||
- `contact_type` - Role/contact type within business
|
||||
- `is_primary` - Primary business for user
|
||||
- `permissions` - JSON array of permission keys
|
||||
|
||||
### d) User Model - businesses() Relationship & Business Switching
|
||||
**LOCATION:** [app/Models/User.php:174-177](app/Models/User.php#L174-L177)
|
||||
|
||||
**FACT:** Mirror relationship accessing same pivot:
|
||||
|
||||
```php
|
||||
public function businesses(): BelongsToMany
|
||||
{
|
||||
return $this->companies(); // Alias for backwards compatibility
|
||||
}
|
||||
|
||||
public function companies(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Business::class, 'business_user')
|
||||
->using(BusinessUser::class)
|
||||
->withPivot('contact_type', 'is_primary', 'permissions')
|
||||
->withTimestamps();
|
||||
}
|
||||
```
|
||||
|
||||
**PRIMARY BUSINESS METHOD:** [app/Models/User.php:131-142](app/Models/User.php#L131-L142)
|
||||
|
||||
```php
|
||||
public function primaryBusiness()
|
||||
{
|
||||
// First try to get explicitly primary business
|
||||
$primary = $this->businesses()->wherePivot('is_primary', true)->first();
|
||||
|
||||
// If no primary set, return the first business
|
||||
if (! $primary) {
|
||||
$primary = $this->businesses()->first();
|
||||
}
|
||||
|
||||
return $primary;
|
||||
}
|
||||
```
|
||||
|
||||
## 2. CURRENT USER'S BUSINESS CONTEXT
|
||||
|
||||
### Session-Based Business Selection
|
||||
**LOCATION:** [app/Helpers/BusinessHelper.php:14-28](app/Helpers/BusinessHelper.php#L14-L28)
|
||||
|
||||
**FACT:** Business context is stored in session under key `current_business_id`:
|
||||
|
||||
```php
|
||||
public static function current(): ?Business
|
||||
{
|
||||
if (! Auth::check()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$businessId = session('current_business_id');
|
||||
|
||||
if ($businessId) {
|
||||
return Business::find($businessId);
|
||||
}
|
||||
|
||||
// Fall back to user's primary business if no session is set
|
||||
return Auth::user()->primaryBusiness();
|
||||
}
|
||||
```
|
||||
|
||||
**BUSINESS SWITCHING:** [app/Http/Controllers/Seller/BrandSwitcherController.php:32](app/Http/Controllers/Seller/BrandSwitcherController.php#L32)
|
||||
```php
|
||||
session(['current_business_id' => $business->id]);
|
||||
```
|
||||
|
||||
**HELPER FUNCTION:** [app/Helpers/helpers.php:5-10](app/Helpers/helpers.php#L5-L10)
|
||||
```php
|
||||
function currentBusiness() {
|
||||
return BusinessHelper::current();
|
||||
}
|
||||
```
|
||||
|
||||
**USAGE PATTERN:**
|
||||
1. User logs in → primary business is default
|
||||
2. User switches businesses → stored in session as `current_business_id`
|
||||
3. `currentBusiness()` helper retrieves from session OR falls back to primary
|
||||
4. Route binding (`{business}` parameter) overrides this for scoped routes
|
||||
|
||||
## 3. PERMISSION CHECKING PATTERN
|
||||
|
||||
### Permission Storage: NOT in permissions table
|
||||
**FACT:** Database `permissions` table is EMPTY (returned `[]`)
|
||||
|
||||
**ACTUAL STORAGE:** Permissions are stored in:
|
||||
- Config file: `config/permissions.php` - Permission definitions
|
||||
- Database pivot: `business_user.permissions` - JSON array per user per business
|
||||
|
||||
### Permission Service Architecture
|
||||
**LOCATION:** [app/Services/PermissionService.php](app/Services/PermissionService.php)
|
||||
|
||||
**PERMISSION CHECK METHOD:** [app/Services/PermissionService.php:17-65](app/Services/PermissionService.php#L17-L65)
|
||||
|
||||
```php
|
||||
public function check(User $user, string $permission, ?Business $business = null): bool
|
||||
{
|
||||
// Get business context
|
||||
$business = $business ?? currentBusiness();
|
||||
if (! $business) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Super admin bypass
|
||||
if ($user->user_type === 'admin') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Business owner bypass
|
||||
if ($business->owner_user_id === $user->id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get user's permissions for this business
|
||||
$businessUser = $user->businesses()
|
||||
->where('businesses.id', $business->id)
|
||||
->first();
|
||||
|
||||
if (! $businessUser) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$userPermissions = $businessUser->pivot->permissions ?? [];
|
||||
|
||||
// Check permission (supports wildcards)
|
||||
return $this->hasPermissionInList($permission, $userPermissions);
|
||||
}
|
||||
```
|
||||
|
||||
**WILDCARD SUPPORT:** [app/Services/PermissionService.php:70-88](app/Services/PermissionService.php#L70-L88)
|
||||
- Exact match: `analytics.overview`
|
||||
- Wildcard match: `analytics.*` matches all `analytics.*` permissions
|
||||
|
||||
**HELPER FUNCTION:** [app/Helpers/BusinessHelper.php:38-55](app/Helpers/BusinessHelper.php#L38-L55)
|
||||
|
||||
```php
|
||||
public static function hasPermission(string $permission): bool
|
||||
{
|
||||
if (! Auth::check()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
$business = self::current();
|
||||
|
||||
if (! $business) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use PermissionService for permission checking
|
||||
$permissionService = app(PermissionService::class);
|
||||
|
||||
return $permissionService->check($user, $permission, $business);
|
||||
}
|
||||
```
|
||||
|
||||
**GLOBAL HELPER:** [app/Helpers/helpers.php:19-24](app/Helpers/helpers.php#L19-L24)
|
||||
```php
|
||||
function hasBusinessPermission(string $permission): bool {
|
||||
return BusinessHelper::hasPermission($permission);
|
||||
}
|
||||
```
|
||||
|
||||
### Permission Checking in Filament
|
||||
**FACT:** No `hasPermissionTo` or `->can()` calls found in `app/Filament/` directory
|
||||
|
||||
**EXPLANATION:** Filament resources use NO explicit permission checks. The admin panel is protected by:
|
||||
- User type check: `$user->user_type === 'admin'` ([app/Models/User.php:112-113](app/Models/User.php#L112-L113))
|
||||
- Admin panel is for super admins only, not business users
|
||||
|
||||
## 4. EXISTING PERMISSIONS LIST
|
||||
|
||||
**DATABASE QUERY RESULT:** `[]` (permissions table is EMPTY)
|
||||
|
||||
**ACTUAL PERMISSION DEFINITIONS:** Stored in `config/permissions.php`
|
||||
|
||||
### Permission Categories:
|
||||
|
||||
#### analytics (6 permissions):
|
||||
- `analytics.overview` - View analytics overview
|
||||
- `analytics.products` - View product analytics
|
||||
- `analytics.marketing` - View marketing analytics
|
||||
- `analytics.sales` - View sales analytics
|
||||
- `analytics.buyers` - View buyer intelligence
|
||||
- `analytics.export` - Export analytics data
|
||||
|
||||
#### products (5 permissions):
|
||||
- `products.view` - View products
|
||||
- `products.create` - Create products
|
||||
- `products.edit` - Edit products
|
||||
- `products.delete` - Delete products
|
||||
- `products.pricing` - Manage pricing
|
||||
|
||||
#### orders (6 permissions):
|
||||
- `orders.view` - View orders
|
||||
- `orders.create` - Create orders
|
||||
- `orders.edit` - Edit orders
|
||||
- `orders.cancel` - Cancel orders
|
||||
- `orders.fulfill` - Fulfill orders
|
||||
- `orders.ship` - Ship orders
|
||||
|
||||
#### customers (5 permissions):
|
||||
- `customers.view` - View customers
|
||||
- `customers.create` - Create customers
|
||||
- `customers.edit` - Edit customers
|
||||
- `customers.delete` - Delete customers
|
||||
- `customers.contact` - Contact customers
|
||||
|
||||
#### financial (7 permissions):
|
||||
- `invoices.view` - View invoices
|
||||
- `invoices.create` - Create invoices
|
||||
- `invoices.edit` - Edit invoices
|
||||
- `invoices.void` - Void invoices
|
||||
- `payments.view` - View payments
|
||||
- `payments.process` - Process payments
|
||||
- `payments.refund` - Refund payments
|
||||
|
||||
#### users (5 permissions):
|
||||
- `users.view` - View users
|
||||
- `users.edit` - Edit users
|
||||
- `users.permissions` - Manage permissions
|
||||
- `users.view_as` - View as other users
|
||||
- `settings.edit` - Edit settings
|
||||
|
||||
### Role Templates (Presets):
|
||||
- `sales_rep` - Sales Representative
|
||||
- `accountant` - Accountant
|
||||
- `inventory_manager` - Inventory Manager
|
||||
- `marketing_manager` - Marketing Manager
|
||||
|
||||
## 5. ROUTE STRUCTURE INVESTIGATION
|
||||
|
||||
### Seller Panel Routes
|
||||
**LOCATION:** [routes/seller.php](routes/seller.php)
|
||||
**PREFIX:** `/s/` for all seller routes
|
||||
|
||||
**STRUCTURE:**
|
||||
- `/s/dashboard` - User-level dashboard (no business context)
|
||||
- `/s/{business}/dashboard` - Business-scoped dashboard
|
||||
- `/s/{business}/products/*` - Business-scoped product routes
|
||||
- `/s/{business}/orders/*` - Business-scoped order routes
|
||||
- `/s/{business}/customers/*` - Business-scoped customer routes
|
||||
- `/s/{business}/analytics/*` - Business-scoped analytics routes
|
||||
- `/s/{business}/settings/*` - Business-scoped settings
|
||||
|
||||
**MIDDLEWARE PROTECTION:**
|
||||
- `seller` - User type must be 'seller'
|
||||
- `auth` - User must be authenticated
|
||||
- `verified` - Email verified
|
||||
- `approved` - Business must be approved (for operational routes)
|
||||
|
||||
**BUSINESS SCOPING:** Routes under `/s/{business}/` automatically scope via route binding ([routes/seller.php:38-56](routes/seller.php#L38-L56))
|
||||
|
||||
### Buyer Panel Routes
|
||||
**PREFIX:** `/b/` (mentioned in CLAUDE.md but not in seller.php)
|
||||
|
||||
### Admin Panel
|
||||
**PREFIX:** `/admin` - Filament admin panel for super admins only
|
||||
|
||||
## SUMMARY - KEY ARCHITECTURAL PATTERNS
|
||||
|
||||
### Multi-Tenancy Implementation
|
||||
❌ **NOT** using spatie/laravel-multitenancy package
|
||||
✅ Custom session-based business scoping
|
||||
|
||||
**How it works:**
|
||||
1. User can belong to multiple businesses (many-to-many)
|
||||
2. Each user has ONE primary business
|
||||
3. User can switch between their businesses via session storage
|
||||
4. Routes with `{business}` parameter enforce access control
|
||||
5. Helper `currentBusiness()` returns session business OR primary business
|
||||
|
||||
### Permission System
|
||||
❌ **NOT** using Spatie permissions table
|
||||
✅ Custom JSON-based permissions in pivot table
|
||||
|
||||
**How it works:**
|
||||
1. Permissions defined in `config/permissions.php`
|
||||
2. Granted per-user per-business in `business_user.permissions` JSON column
|
||||
3. Checked via `PermissionService` with wildcard support
|
||||
4. Super admin & business owner bypass all checks
|
||||
5. Helper: `hasBusinessPermission('permission.key')`
|
||||
|
||||
### Business Scoping for Queries
|
||||
**Pattern:** Manual `business_id` scoping using route-resolved business
|
||||
|
||||
**Example from route binding:** [routes/seller.php:48-53](routes/seller.php#L48-L53)
|
||||
```php
|
||||
$product = \App\Models\Product::where('hashid', $value)
|
||||
->whereHas('brand', function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
})
|
||||
->firstOrFail();
|
||||
```
|
||||
|
||||
**Critical Rule:** Always scope by `business_id` BEFORE finding by ID to prevent cross-tenant access
|
||||
|
||||
ANSWER: Filament Usage in Your Codebase
|
||||
Filament is ONLY used at /admin path
|
||||
Evidence:
|
||||
1. Single Filament Panel Configuration
|
||||
app/Providers/Filament/AdminPanelProvider.php:33
|
||||
return $panel
|
||||
->default()
|
||||
->id('admin')
|
||||
->path('admin') // ← ONLY /admin path
|
||||
2. No Filament in Controllers
|
||||
Search result: No use Filament\ statements found in app/Http/Controllers/ 3. Seller Panel Uses Blade + DaisyUI
|
||||
Found extensive Blade templates in resources/views/seller/:
|
||||
seller/analytics/*.blade.php - Analytics views
|
||||
seller/settings/*.blade.php - Settings views
|
||||
seller/products/*.blade.php - Product management
|
||||
seller/orders/*.blade.php - Order management
|
||||
4. Filament Resources Directory Structure
|
||||
app/Filament/
|
||||
├── Resources/ ← Admin panel resources only
|
||||
└── Widgets/ ← Admin panel widgets only
|
||||
All these resources are auto-discovered by the admin panel only.
|
||||
Conclusion
|
||||
✅ CORRECT: Filament is exclusively for /admin (super admin panel)
|
||||
✅ CORRECT: /s/ seller routes use Blade + DaisyUI
|
||||
✅ CORRECT: Marketing/messaging features for sellers should NOT use Filament For the Marketing/Messaging implementation:
|
||||
Use Blade templates in resources/views/seller/marketing/
|
||||
Use DaisyUI components for UI
|
||||
Use standard Laravel controllers in app/Http/Controllers/Seller/
|
||||
Follow existing patterns from analytics/products/orders
|
||||
Your implementation guide's Filament resources should be adapted to Blade + DaisyUI instead.
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,10 @@ class Kernel extends ConsoleKernel
|
||||
*/
|
||||
protected function schedule(Schedule $schedule)
|
||||
{
|
||||
// $schedule->command('inspire')->hourly();
|
||||
// Check for scheduled broadcasts every minute
|
||||
$schedule->job(new \App\Jobs\Marketing\ProcessScheduledBroadcastsJob)
|
||||
->everyMinute()
|
||||
->withoutOverlapping();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
145
app/Filament/Resources/TemplateCategoryResource.php
Normal file
145
app/Filament/Resources/TemplateCategoryResource.php
Normal file
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\TemplateCategoryResource\Pages;
|
||||
use App\Models\Marketing\TemplateCategory;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Components\ColorPicker;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Grid;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\ColorColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class TemplateCategoryResource extends Resource
|
||||
{
|
||||
protected static ?string $model = TemplateCategory::class;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-folder';
|
||||
|
||||
protected static \UnitEnum|string|null $navigationGroup = 'Marketing';
|
||||
|
||||
protected static ?int $navigationSort = 1;
|
||||
|
||||
protected static ?string $navigationLabel = 'Template Categories';
|
||||
|
||||
protected static ?string $modelLabel = 'Template Category';
|
||||
|
||||
protected static ?string $pluralModelLabel = 'Template Categories';
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
TextInput::make('name')
|
||||
->label('Category Name')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->live(onBlur: true)
|
||||
->afterStateUpdated(function (string $operation, $state, Forms\Set $set) {
|
||||
if ($operation === 'create') {
|
||||
$set('slug', Str::slug($state));
|
||||
}
|
||||
}),
|
||||
|
||||
TextInput::make('slug')
|
||||
->label('Slug')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->unique(ignoreRecord: true)
|
||||
->alphaDash(),
|
||||
|
||||
TextInput::make('icon')
|
||||
->label('Icon')
|
||||
->helperText('Heroicon name (e.g., heroicon-o-envelope)')
|
||||
->maxLength(255),
|
||||
|
||||
ColorPicker::make('color')
|
||||
->label('Color')
|
||||
->helperText('Category color for visual organization'),
|
||||
|
||||
TextInput::make('sort_order')
|
||||
->label('Sort Order')
|
||||
->numeric()
|
||||
->default(0)
|
||||
->helperText('Lower numbers appear first'),
|
||||
]),
|
||||
|
||||
Textarea::make('description')
|
||||
->label('Description')
|
||||
->rows(3)
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->label('Category')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
|
||||
TextColumn::make('slug')
|
||||
->label('Slug')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
|
||||
ColorColumn::make('color')
|
||||
->label('Color'),
|
||||
|
||||
TextColumn::make('icon')
|
||||
->label('Icon')
|
||||
->badge(),
|
||||
|
||||
TextColumn::make('templates_count')
|
||||
->label('Templates')
|
||||
->counts('templates')
|
||||
->sortable(),
|
||||
|
||||
TextColumn::make('sort_order')
|
||||
->label('Order')
|
||||
->sortable(),
|
||||
|
||||
TextColumn::make('created_at')
|
||||
->label('Created')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
//
|
||||
])
|
||||
->actions([
|
||||
EditAction::make(),
|
||||
DeleteAction::make(),
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
]),
|
||||
])
|
||||
->defaultSort('sort_order');
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListTemplateCategories::route('/'),
|
||||
'create' => Pages\CreateTemplateCategory::route('/create'),
|
||||
'edit' => Pages\EditTemplateCategory::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\TemplateCategoryResource\Pages;
|
||||
|
||||
use App\Filament\Resources\TemplateCategoryResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateTemplateCategory extends CreateRecord
|
||||
{
|
||||
protected static string $resource = TemplateCategoryResource::class;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\TemplateCategoryResource\Pages;
|
||||
|
||||
use App\Filament\Resources\TemplateCategoryResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditTemplateCategory extends EditRecord
|
||||
{
|
||||
protected static string $resource = TemplateCategoryResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\TemplateCategoryResource\Pages;
|
||||
|
||||
use App\Filament\Resources\TemplateCategoryResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListTemplateCategories extends ListRecords
|
||||
{
|
||||
protected static string $resource = TemplateCategoryResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
331
app/Filament/Resources/TemplateResource.php
Normal file
331
app/Filament/Resources/TemplateResource.php
Normal file
@@ -0,0 +1,331 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\TemplateResource\Pages;
|
||||
use App\Models\Marketing\Template;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Forms\Components\RichEditor;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Grid;
|
||||
use Filament\Schemas\Components\Tabs;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Filters\TernaryFilter;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class TemplateResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Template::class;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-envelope';
|
||||
|
||||
protected static \UnitEnum|string|null $navigationGroup = 'Marketing';
|
||||
|
||||
protected static ?int $navigationSort = 2;
|
||||
|
||||
protected static ?string $navigationLabel = 'Email Templates';
|
||||
|
||||
protected static ?string $modelLabel = 'Email Template';
|
||||
|
||||
protected static ?string $pluralModelLabel = 'Email Templates';
|
||||
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
// Temporarily disabled for performance testing
|
||||
return null;
|
||||
// return cache()->remember('template_system_count', 60, function () {
|
||||
// return static::getModel()::where('is_system_template', true)->count() ?: null;
|
||||
// });
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Tabs::make('Template Details')
|
||||
->tabs([
|
||||
Tab::make('Basic Information')
|
||||
->schema([
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
Select::make('business_id')
|
||||
->label('Business')
|
||||
->relationship('business', 'name')
|
||||
->required()
|
||||
->searchable()
|
||||
->preload()
|
||||
->helperText('Business that owns this template'),
|
||||
|
||||
Select::make('category_id')
|
||||
->label('Category')
|
||||
->relationship('category', 'name')
|
||||
->required()
|
||||
->searchable()
|
||||
->preload()
|
||||
->createOptionForm([
|
||||
TextInput::make('name')->required(),
|
||||
TextInput::make('slug')->required(),
|
||||
Textarea::make('description'),
|
||||
]),
|
||||
|
||||
TextInput::make('name')
|
||||
->label('Template Name')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->columnSpanFull(),
|
||||
|
||||
Textarea::make('description')
|
||||
->label('Description')
|
||||
->rows(3)
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
|
||||
Grid::make(3)
|
||||
->schema([
|
||||
Select::make('template_type')
|
||||
->label('Type')
|
||||
->options([
|
||||
'email' => 'Email',
|
||||
'sms' => 'SMS',
|
||||
'push' => 'Push Notification',
|
||||
])
|
||||
->required()
|
||||
->default('email'),
|
||||
|
||||
Toggle::make('is_system_template')
|
||||
->label('System Template')
|
||||
->helperText('Available to all businesses')
|
||||
->default(false),
|
||||
|
||||
Toggle::make('is_public')
|
||||
->label('Public Template')
|
||||
->helperText('Visible in template library')
|
||||
->default(false),
|
||||
]),
|
||||
]),
|
||||
|
||||
Tab::make('Content')
|
||||
->schema([
|
||||
RichEditor::make('html_content')
|
||||
->label('HTML Content')
|
||||
->required()
|
||||
->columnSpanFull()
|
||||
->fileAttachmentsDirectory('template-assets'),
|
||||
|
||||
Textarea::make('plain_text')
|
||||
->label('Plain Text Version')
|
||||
->helperText('Fallback for email clients that don\'t support HTML')
|
||||
->rows(10)
|
||||
->columnSpanFull(),
|
||||
|
||||
Textarea::make('mjml_content')
|
||||
->label('MJML Source')
|
||||
->helperText('Optional: MJML source code for the template')
|
||||
->rows(10)
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
|
||||
Tab::make('Settings')
|
||||
->schema([
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
TextInput::make('thumbnail')
|
||||
->label('Thumbnail URL')
|
||||
->url()
|
||||
->maxLength(255),
|
||||
|
||||
TextInput::make('version')
|
||||
->label('Version')
|
||||
->numeric()
|
||||
->default(1)
|
||||
->disabled(),
|
||||
|
||||
TextInput::make('usage_count')
|
||||
->label('Usage Count')
|
||||
->numeric()
|
||||
->default(0)
|
||||
->disabled(),
|
||||
|
||||
TextInput::make('usage_limit')
|
||||
->label('Usage Limit')
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->helperText('Maximum number of times this template can be used (leave empty for unlimited)'),
|
||||
|
||||
Select::make('usage_period')
|
||||
->label('Usage Period')
|
||||
->options([
|
||||
'monthly' => 'Monthly',
|
||||
'yearly' => 'Yearly',
|
||||
'lifetime' => 'Lifetime',
|
||||
])
|
||||
->helperText('Time period for usage limit')
|
||||
->visible(fn ($get) => filled($get('usage_limit'))),
|
||||
|
||||
Toggle::make('is_premium')
|
||||
->label('Premium Template')
|
||||
->helperText('Requires premium subscription to use')
|
||||
->default(false),
|
||||
|
||||
Select::make('created_by')
|
||||
->label('Created By')
|
||||
->relationship('creator', 'email')
|
||||
->disabled(),
|
||||
]),
|
||||
|
||||
Textarea::make('tags')
|
||||
->label('Tags (JSON)')
|
||||
->helperText('Enter tags as JSON array: ["tag1", "tag2"]')
|
||||
->rows(3)
|
||||
->columnSpanFull(),
|
||||
|
||||
Textarea::make('design_json')
|
||||
->label('Design JSON')
|
||||
->helperText('JSON representation of the template design')
|
||||
->rows(10)
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->modifyQueryUsing(fn ($query) => $query->with(['business', 'category', 'creator']))
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->label('Template')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
|
||||
TextColumn::make('category.name')
|
||||
->label('Category')
|
||||
->badge()
|
||||
->searchable()
|
||||
->sortable(),
|
||||
|
||||
TextColumn::make('business.name')
|
||||
->label('Business')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
|
||||
TextColumn::make('template_type')
|
||||
->label('Type')
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'email' => 'success',
|
||||
'sms' => 'warning',
|
||||
'push' => 'info',
|
||||
default => 'gray',
|
||||
})
|
||||
->sortable(),
|
||||
|
||||
IconColumn::make('is_system_template')
|
||||
->label('System')
|
||||
->boolean()
|
||||
->sortable(),
|
||||
|
||||
IconColumn::make('is_public')
|
||||
->label('Public')
|
||||
->boolean()
|
||||
->sortable(),
|
||||
|
||||
IconColumn::make('is_premium')
|
||||
->label('Premium')
|
||||
->boolean()
|
||||
->sortable(),
|
||||
|
||||
TextColumn::make('usage_count')
|
||||
->label('Uses')
|
||||
->numeric()
|
||||
->sortable(),
|
||||
|
||||
TextColumn::make('usage_limit')
|
||||
->label('Limit')
|
||||
->numeric()
|
||||
->sortable()
|
||||
->default('Unlimited')
|
||||
->formatStateUsing(fn ($state) => $state ?? 'Unlimited'),
|
||||
|
||||
TextColumn::make('last_used_at')
|
||||
->label('Last Used')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
|
||||
TextColumn::make('created_at')
|
||||
->label('Created')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
TernaryFilter::make('is_system_template')
|
||||
->label('System Template')
|
||||
->placeholder('All templates')
|
||||
->trueLabel('System templates only')
|
||||
->falseLabel('Business templates only'),
|
||||
|
||||
TernaryFilter::make('is_public')
|
||||
->label('Public Template')
|
||||
->placeholder('All visibility')
|
||||
->trueLabel('Public only')
|
||||
->falseLabel('Private only'),
|
||||
|
||||
SelectFilter::make('category_id')
|
||||
->label('Category')
|
||||
->relationship('category', 'name')
|
||||
->searchable()
|
||||
->preload(),
|
||||
|
||||
SelectFilter::make('business_id')
|
||||
->label('Business')
|
||||
->relationship('business', 'name')
|
||||
->searchable()
|
||||
->preload(),
|
||||
|
||||
SelectFilter::make('template_type')
|
||||
->label('Type')
|
||||
->options([
|
||||
'email' => 'Email',
|
||||
'sms' => 'SMS',
|
||||
'push' => 'Push',
|
||||
]),
|
||||
])
|
||||
->actions([
|
||||
ViewAction::make(),
|
||||
EditAction::make(),
|
||||
DeleteAction::make(),
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
]),
|
||||
])
|
||||
->defaultSort('created_at', 'desc');
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListTemplates::route('/'),
|
||||
'create' => Pages\CreateTemplate::route('/create'),
|
||||
'view' => Pages\ViewTemplate::route('/{record}'),
|
||||
'edit' => Pages\EditTemplate::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\TemplateResource\Pages;
|
||||
|
||||
use App\Filament\Resources\TemplateResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateTemplate extends CreateRecord
|
||||
{
|
||||
protected static string $resource = TemplateResource::class;
|
||||
|
||||
protected function mutateFormDataBeforeCreate(array $data): array
|
||||
{
|
||||
$data['created_by'] = auth()->id();
|
||||
$data['version'] = 1;
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\TemplateResource\Pages;
|
||||
|
||||
use App\Filament\Resources\TemplateResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditTemplate extends EditRecord
|
||||
{
|
||||
protected static string $resource = TemplateResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\ViewAction::make(),
|
||||
Actions\DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function mutateFormDataBeforeSave(array $data): array
|
||||
{
|
||||
$data['updated_by'] = auth()->id();
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\TemplateResource\Pages;
|
||||
|
||||
use App\Filament\Resources\TemplateResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListTemplates extends ListRecords
|
||||
{
|
||||
protected static string $resource = TemplateResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\TemplateResource\Pages;
|
||||
|
||||
use App\Filament\Resources\TemplateResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewTemplate extends ViewRecord
|
||||
{
|
||||
protected static string $resource = TemplateResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\EditAction::make(),
|
||||
Actions\DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
71
app/Http/Controllers/AdminController.php
Normal file
71
app/Http/Controllers/AdminController.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Models\SubscriptionInvoice;
|
||||
|
||||
class AdminController extends Controller
|
||||
{
|
||||
public function billingOverview()
|
||||
{
|
||||
// Get all businesses with their latest subscriptions and invoices
|
||||
$businesses = Business::with([
|
||||
'subscription',
|
||||
'subscription.defaultPaymentMethod',
|
||||
])
|
||||
->select('id', 'name', 'slug', 'email', 'created_at')
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(function ($business) {
|
||||
$subscription = $business->subscription;
|
||||
|
||||
// Get invoices for this business
|
||||
$totalRevenue = SubscriptionInvoice::where('business_id', $business->id)
|
||||
->where('status', 'paid')
|
||||
->sum('amount');
|
||||
|
||||
$pendingAmount = SubscriptionInvoice::where('business_id', $business->id)
|
||||
->where('status', 'pending')
|
||||
->sum('amount');
|
||||
|
||||
$pastDueAmount = SubscriptionInvoice::where('business_id', $business->id)
|
||||
->where('status', 'past_due')
|
||||
->sum('amount');
|
||||
|
||||
return [
|
||||
'id' => $business->id,
|
||||
'name' => $business->name,
|
||||
'slug' => $business->slug,
|
||||
'email' => $business->email,
|
||||
'joined' => $business->created_at,
|
||||
'plan_id' => $subscription?->plan_id ?? 'none',
|
||||
'plan_name' => $subscription?->plan_name ?? 'No Plan',
|
||||
'plan_price' => $subscription?->plan_price ?? 0,
|
||||
'subscription_status' => $subscription?->status ?? 'inactive',
|
||||
'current_period_end' => $subscription?->current_period_end,
|
||||
'has_scheduled_downgrade' => $subscription?->hasScheduledDowngrade() ?? false,
|
||||
'scheduled_plan_name' => $subscription?->scheduled_plan_name,
|
||||
'scheduled_change_date' => $subscription?->scheduled_change_date,
|
||||
'total_revenue' => $totalRevenue,
|
||||
'pending_amount' => $pendingAmount,
|
||||
'past_due_amount' => $pastDueAmount,
|
||||
'payment_method' => $subscription?->defaultPaymentMethod
|
||||
? $subscription->defaultPaymentMethod->card_brand.' •••• '.$subscription->defaultPaymentMethod->last4
|
||||
: 'None',
|
||||
];
|
||||
});
|
||||
|
||||
// Calculate summary stats
|
||||
$stats = [
|
||||
'total_businesses' => $businesses->count(),
|
||||
'active_subscriptions' => $businesses->where('subscription_status', 'active')->count(),
|
||||
'total_mrr' => $businesses->where('subscription_status', 'active')->sum('plan_price'),
|
||||
'total_revenue' => $businesses->sum('total_revenue'),
|
||||
'total_pending' => $businesses->sum('pending_amount'),
|
||||
'total_past_due' => $businesses->sum('past_due_amount'),
|
||||
];
|
||||
|
||||
return view('admin.billing-overview', compact('businesses', 'stats'));
|
||||
}
|
||||
}
|
||||
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'
|
||||
));
|
||||
}
|
||||
}
|
||||
173
app/Http/Controllers/Analytics/MarketingAnalyticsController.php
Normal file
173
app/Http/Controllers/Analytics/MarketingAnalyticsController.php
Normal file
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Analytics;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Analytics\EmailCampaign;
|
||||
use App\Models\Analytics\EmailInteraction;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class MarketingAnalyticsController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
if (! hasBusinessPermission('analytics.marketing')) {
|
||||
abort(403, 'Unauthorized to view marketing analytics');
|
||||
}
|
||||
|
||||
$business = currentBusiness();
|
||||
$period = $request->input('period', '30');
|
||||
$startDate = now()->subDays((int) $period);
|
||||
|
||||
// Campaign overview metrics
|
||||
$metrics = [
|
||||
'total_campaigns' => EmailCampaign::forBusiness($business->id)->where('created_at', '>=', $startDate)->count(),
|
||||
'total_sent' => EmailCampaign::forBusiness($business->id)->where('created_at', '>=', $startDate)->sum('total_sent'),
|
||||
'total_delivered' => EmailCampaign::forBusiness($business->id)->where('created_at', '>=', $startDate)->sum('total_delivered'),
|
||||
'total_opened' => EmailCampaign::forBusiness($business->id)->where('created_at', '>=', $startDate)->sum('total_opened'),
|
||||
'total_clicked' => EmailCampaign::forBusiness($business->id)->where('created_at', '>=', $startDate)->sum('total_clicked'),
|
||||
];
|
||||
|
||||
// Calculate average rates
|
||||
$metrics['avg_open_rate'] = $metrics['total_delivered'] > 0
|
||||
? round(($metrics['total_opened'] / $metrics['total_delivered']) * 100, 2)
|
||||
: 0;
|
||||
|
||||
$metrics['avg_click_rate'] = $metrics['total_delivered'] > 0
|
||||
? round(($metrics['total_clicked'] / $metrics['total_delivered']) * 100, 2)
|
||||
: 0;
|
||||
|
||||
// Campaign performance
|
||||
$campaigns = EmailCampaign::forBusiness($business->id)->where('created_at', '>=', $startDate)
|
||||
->orderByDesc('sent_at')
|
||||
->with('emailInteractions')
|
||||
->paginate(20);
|
||||
|
||||
// Email engagement over time
|
||||
$engagementTrend = EmailInteraction::forBusiness($business->id)->where('sent_at', '>=', $startDate)
|
||||
->select(
|
||||
DB::raw('DATE(sent_at) as date'),
|
||||
DB::raw('COUNT(*) as sent'),
|
||||
DB::raw('SUM(CASE WHEN first_opened_at IS NOT NULL THEN 1 ELSE 0 END) as opened'),
|
||||
DB::raw('SUM(CASE WHEN first_clicked_at IS NOT NULL THEN 1 ELSE 0 END) as clicked')
|
||||
)
|
||||
->groupBy('date')
|
||||
->orderBy('date')
|
||||
->get();
|
||||
|
||||
// Top performing campaigns
|
||||
$topCampaigns = EmailCampaign::forBusiness($business->id)->where('sent_at', '>=', $startDate)
|
||||
->where('total_sent', '>', 0)
|
||||
->orderByRaw('(total_clicked / total_sent) DESC')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
// Email client breakdown
|
||||
$emailClients = EmailInteraction::forBusiness($business->id)->where('sent_at', '>=', $startDate)
|
||||
->whereNotNull('email_client')
|
||||
->select('email_client')
|
||||
->selectRaw('COUNT(*) as count')
|
||||
->groupBy('email_client')
|
||||
->orderByDesc('count')
|
||||
->get();
|
||||
|
||||
// Device type breakdown
|
||||
$deviceTypes = EmailInteraction::forBusiness($business->id)->where('sent_at', '>=', $startDate)
|
||||
->whereNotNull('device_type')
|
||||
->select('device_type')
|
||||
->selectRaw('COUNT(*) as count')
|
||||
->groupBy('device_type')
|
||||
->orderByDesc('count')
|
||||
->get();
|
||||
|
||||
// Engagement score distribution
|
||||
$engagementScores = EmailInteraction::forBusiness($business->id)->where('sent_at', '>=', $startDate)
|
||||
->select(
|
||||
DB::raw("CASE
|
||||
WHEN engagement_score >= 80 THEN 'High'
|
||||
WHEN engagement_score >= 50 THEN 'Medium'
|
||||
WHEN engagement_score > 0 THEN 'Low'
|
||||
ELSE 'None'
|
||||
END as score_range"),
|
||||
DB::raw('COUNT(*) as count')
|
||||
)
|
||||
->groupBy('score_range')
|
||||
->get();
|
||||
|
||||
return view('seller.analytics.marketing', compact(
|
||||
'business',
|
||||
'period',
|
||||
'metrics',
|
||||
'campaigns',
|
||||
'engagementTrend',
|
||||
'topCampaigns',
|
||||
'emailClients',
|
||||
'deviceTypes',
|
||||
'engagementScores'
|
||||
));
|
||||
}
|
||||
|
||||
public function campaign(Request $request, EmailCampaign $campaign)
|
||||
{
|
||||
if (! hasBusinessPermission('analytics.marketing')) {
|
||||
abort(403, 'Unauthorized to view marketing analytics');
|
||||
}
|
||||
|
||||
// Verify campaign belongs to user's business
|
||||
if ($campaign->business_id !== currentBusinessId()) {
|
||||
abort(403, 'Unauthorized to view this campaign');
|
||||
}
|
||||
|
||||
// Campaign metrics
|
||||
$metrics = [
|
||||
'total_sent' => $campaign->total_sent,
|
||||
'total_delivered' => $campaign->total_delivered,
|
||||
'total_bounced' => $campaign->total_bounced,
|
||||
'total_opened' => $campaign->total_opened,
|
||||
'total_clicked' => $campaign->total_clicked,
|
||||
'open_rate' => $campaign->open_rate,
|
||||
'click_rate' => $campaign->click_rate,
|
||||
'bounce_rate' => $campaign->total_sent > 0
|
||||
? round(($campaign->total_bounced / $campaign->total_sent) * 100, 2)
|
||||
: 0,
|
||||
];
|
||||
|
||||
// Interaction timeline
|
||||
$timeline = EmailInteraction::forBusiness($campaign->business_id)->where('campaign_id', $campaign->id)
|
||||
->select(
|
||||
DB::raw('DATE(sent_at) as date'),
|
||||
DB::raw('SUM(open_count) as opens'),
|
||||
DB::raw('SUM(click_count) as clicks')
|
||||
)
|
||||
->groupBy('date')
|
||||
->orderBy('date')
|
||||
->get();
|
||||
|
||||
// Top engaged recipients
|
||||
$topRecipients = EmailInteraction::forBusiness($campaign->business_id)->where('campaign_id', $campaign->id)
|
||||
->orderByDesc('engagement_score')
|
||||
->limit(20)
|
||||
->with('recipientUser')
|
||||
->get();
|
||||
|
||||
// Click breakdown by URL
|
||||
$clicksByUrl = DB::table('email_clicks')
|
||||
->join('email_interactions', 'email_clicks.email_interaction_id', '=', 'email_interactions.id')
|
||||
->where('email_interactions.campaign_id', $campaign->id)
|
||||
->select('email_clicks.url', 'email_clicks.link_identifier')
|
||||
->selectRaw('COUNT(*) as click_count')
|
||||
->selectRaw('COUNT(DISTINCT email_clicks.email_interaction_id) as unique_clicks')
|
||||
->groupBy('email_clicks.url', 'email_clicks.link_identifier')
|
||||
->orderByDesc('click_count')
|
||||
->get();
|
||||
|
||||
return view('seller.analytics.campaign-detail', compact(
|
||||
'campaign',
|
||||
'metrics',
|
||||
'timeline',
|
||||
'topRecipients',
|
||||
'clicksByUrl'
|
||||
));
|
||||
}
|
||||
}
|
||||
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', [])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -35,10 +35,13 @@ class AnalyticsController extends Controller
|
||||
'topCustomers' => $this->getTopCustomers($brandIds, 10),
|
||||
];
|
||||
|
||||
return view('seller.analytics.index', [
|
||||
$period = $request->input('period', '30');
|
||||
|
||||
return view('seller.analytics.dashboard', [
|
||||
'user' => $user,
|
||||
'business' => $business,
|
||||
'analyticsData' => $data,
|
||||
'period' => $period,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -191,4 +194,127 @@ class AnalyticsController extends Controller
|
||||
|
||||
return $topCustomers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display sales analytics
|
||||
*/
|
||||
public function sales(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
$business = $user->primaryBusiness();
|
||||
|
||||
if (! $business) {
|
||||
return redirect()->route('seller.setup');
|
||||
}
|
||||
|
||||
$period = $request->input('period', '30');
|
||||
|
||||
return view('seller.analytics.sales', compact('user', 'business', 'period'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display buyer analytics
|
||||
*/
|
||||
public function buyers(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
$business = $user->primaryBusiness();
|
||||
|
||||
if (! $business) {
|
||||
return redirect()->route('seller.setup');
|
||||
}
|
||||
|
||||
$period = $request->input('period', '30');
|
||||
|
||||
return view('seller.analytics.buyers', compact('user', 'business', 'period'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display product analytics
|
||||
*/
|
||||
public function products(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
$business = $user->primaryBusiness();
|
||||
|
||||
if (! $business) {
|
||||
return redirect()->route('seller.setup');
|
||||
}
|
||||
|
||||
$period = $request->input('period', '30');
|
||||
|
||||
// Get filtered brand IDs for multi-tenancy
|
||||
$brandIds = \App\Http\Controllers\Seller\BrandSwitcherController::getFilteredBrandIds();
|
||||
|
||||
// Get product IDs for the filtered brands
|
||||
$productIds = \App\Models\Product::whereIn('brand_id', $brandIds)->pluck('id')->toArray();
|
||||
|
||||
// Get engagement breakdown - count of different engagement types
|
||||
$engagementBreakdown = [
|
||||
'zoomed_image' => \App\Models\Analytics\ProductView::whereIn('product_id', $productIds)
|
||||
->where('zoomed_image', true)
|
||||
->count(),
|
||||
'watched_video' => \App\Models\Analytics\ProductView::whereIn('product_id', $productIds)
|
||||
->where('watched_video', true)
|
||||
->count(),
|
||||
'downloaded_spec' => \App\Models\Analytics\ProductView::whereIn('product_id', $productIds)
|
||||
->where('downloaded_spec', true)
|
||||
->count(),
|
||||
'added_to_cart' => \App\Models\Analytics\ProductView::whereIn('product_id', $productIds)
|
||||
->where('added_to_cart', true)
|
||||
->count(),
|
||||
'added_to_wishlist' => \App\Models\Analytics\ProductView::whereIn('product_id', $productIds)
|
||||
->where('added_to_wishlist', true)
|
||||
->count(),
|
||||
];
|
||||
|
||||
// Get product metrics - aggregate view data per product
|
||||
$productMetrics = \App\Models\Analytics\ProductView::whereIn('product_id', $productIds)
|
||||
->selectRaw('
|
||||
product_id,
|
||||
COUNT(*) as total_views,
|
||||
COUNT(DISTINCT buyer_business_id) as unique_buyers,
|
||||
AVG(time_on_page) as avg_time_on_page,
|
||||
SUM(CASE WHEN added_to_cart = true THEN 1 ELSE 0 END) as cart_additions
|
||||
')
|
||||
->with('product.brand')
|
||||
->groupBy('product_id')
|
||||
->orderByDesc('total_views')
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
return view('seller.analytics.products', compact('user', 'business', 'period', 'engagementBreakdown', 'productMetrics'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display detailed analytics for a specific product
|
||||
*/
|
||||
public function productDetail(Request $request, Product $product)
|
||||
{
|
||||
$user = $request->user();
|
||||
$business = $user->primaryBusiness();
|
||||
|
||||
if (! $business) {
|
||||
return redirect()->route('seller.setup');
|
||||
}
|
||||
|
||||
return view('seller.analytics.product-detail', compact('user', 'business', 'product'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display marketing analytics
|
||||
*/
|
||||
public function marketing(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
$business = $user->primaryBusiness();
|
||||
|
||||
if (! $business) {
|
||||
return redirect()->route('seller.setup');
|
||||
}
|
||||
|
||||
$period = $request->input('period', '30');
|
||||
|
||||
return view('seller.analytics.marketing', compact('user', 'business', 'period'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
@@ -19,13 +25,16 @@ class UserController extends Controller
|
||||
$business = $user->businesses()->first();
|
||||
|
||||
if (! $business) {
|
||||
return redirect()->route($this->getRoutePrefix().'.dashboard')
|
||||
->with('error', 'No business associated with your account.');
|
||||
// If no business found, redirect to home
|
||||
return redirect('/')->with('error', 'No business associated with your account.');
|
||||
}
|
||||
|
||||
// Load users with their pivot data (contact_type, is_primary, permissions)
|
||||
// Load users with their pivot data (contact_type, is_primary, permissions, role_template)
|
||||
// Exclude the business owner from the list
|
||||
$users = $business->users()
|
||||
->withPivot('contact_type', 'is_primary', 'permissions')
|
||||
->where('users.id', '!=', $business->owner_user_id)
|
||||
->withPivot('contact_type', 'is_primary', 'permissions', 'role', 'role_template')
|
||||
->with('roles')
|
||||
->orderBy('is_primary', 'desc')
|
||||
->orderBy('first_name')
|
||||
->get();
|
||||
@@ -33,6 +42,8 @@ class UserController extends Controller
|
||||
return view('business.users.index', [
|
||||
'business' => $business,
|
||||
'users' => $users,
|
||||
'roleTemplates' => $this->permissionService->getRoleTemplates(),
|
||||
'permissionCategories' => $this->permissionService->getPermissionsByCategory(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
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();
|
||||
|
||||
364
app/Http/Controllers/Seller/BatchController.php
Normal file
364
app/Http/Controllers/Seller/BatchController.php
Normal file
@@ -0,0 +1,364 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Batch;
|
||||
use App\Models\Business;
|
||||
use App\Models\Product;
|
||||
use App\Services\QrCodeService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class BatchController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of batches for the business
|
||||
*/
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
// Build query for batches
|
||||
$query = Batch::where('business_id', $business->id)
|
||||
->with(['product.brand', 'coaFiles'])
|
||||
->orderBy('production_date', 'desc');
|
||||
|
||||
// Search filter
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('batch_number', 'LIKE', "%{$search}%")
|
||||
->orWhere('test_id', 'LIKE', "%{$search}%")
|
||||
->orWhere('lot_number', 'LIKE', "%{$search}%")
|
||||
->orWhereHas('product', function ($productQuery) use ($search) {
|
||||
$productQuery->where('name', 'LIKE', "%{$search}%");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
$batches = $query->paginate(20)->withQueryString();
|
||||
|
||||
// Separate active and inactive batches
|
||||
$activeBatches = $batches->filter(fn ($batch) => $batch->is_active);
|
||||
$inactiveBatches = $batches->filter(fn ($batch) => ! $batch->is_active);
|
||||
|
||||
return view('seller.batches.index', compact('business', 'batches', 'activeBatches', 'inactiveBatches'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for creating a new batch
|
||||
*/
|
||||
public function create(Request $request, Business $business)
|
||||
{
|
||||
// Get products owned by this business
|
||||
$products = Product::whereHas('brand', function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
})->orderBy('name', 'asc')->get();
|
||||
|
||||
// For the new architecture, components are products (the view expects $components)
|
||||
$components = $products;
|
||||
|
||||
// Get existing component batches that can be used as sources for homogenized batches
|
||||
$componentBatches = Batch::where('business_id', $business->id)
|
||||
->where('quantity_remaining', '>', 0)
|
||||
->where('is_active', true)
|
||||
->where('is_quarantined', false)
|
||||
->with('component')
|
||||
->orderBy('batch_number')
|
||||
->get();
|
||||
|
||||
return view('seller.batches.create', compact('business', 'products', 'components', 'componentBatches'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created batch
|
||||
*/
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
// Determine max value based on unit (% vs mg/g, mg/ml, mg/unit)
|
||||
$maxValue = $request->cannabinoid_unit === '%' ? 100 : 1000;
|
||||
|
||||
$validated = $request->validate([
|
||||
'product_id' => 'required|exists:products,id',
|
||||
'cannabinoid_unit' => 'required|string|in:%,MG/ML,MG/G,MG/UNIT',
|
||||
'batch_number' => 'nullable|string|max:100|unique:batches,batch_number',
|
||||
'production_date' => 'nullable|date',
|
||||
'test_date' => 'nullable|date',
|
||||
'test_id' => 'nullable|string|max:100',
|
||||
'lot_number' => 'nullable|string|max:100',
|
||||
'lab_name' => 'nullable|string|max:255',
|
||||
'thc_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'thca_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'cbd_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'cbda_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'cbg_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'cbn_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'delta_9_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'total_terps_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'notes' => 'nullable|string',
|
||||
'coa_files.*' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240', // 10MB max per file
|
||||
]);
|
||||
|
||||
// Verify product belongs to this business
|
||||
$product = Product::whereHas('brand', function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
})->findOrFail($validated['product_id']);
|
||||
|
||||
// Set business_id
|
||||
$validated['business_id'] = $business->id;
|
||||
$validated['is_active'] = true; // New batches are active by default
|
||||
|
||||
// Create batch (calculations happen in model boot method)
|
||||
$batch = Batch::create($validated);
|
||||
|
||||
// Handle COA file uploads
|
||||
if ($request->hasFile('coa_files')) {
|
||||
foreach ($request->file('coa_files') as $index => $file) {
|
||||
$storagePath = "businesses/{$business->uuid}/batches/{$batch->id}/coas";
|
||||
$fileName = uniqid().'.'.$file->getClientOriginalExtension();
|
||||
$filePath = $file->storeAs($storagePath, $fileName, 'public');
|
||||
|
||||
$batch->coaFiles()->create([
|
||||
'file_name' => $file->getClientOriginalName(),
|
||||
'file_path' => $filePath,
|
||||
'file_type' => $file->getClientOriginalExtension(),
|
||||
'file_size' => $file->getSize(),
|
||||
'is_primary' => $index === 0,
|
||||
'display_order' => $index,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-generate QR code for the new batch (with brand logo if available)
|
||||
$qrService = app(QrCodeService::class);
|
||||
$qrService->generateWithLogo($batch);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.batches.index', $business->slug)
|
||||
->with('success', 'Batch created successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for editing the specified batch
|
||||
*/
|
||||
public function edit(Request $request, Business $business, Batch $batch)
|
||||
{
|
||||
// Verify batch belongs to this business
|
||||
if ($batch->business_id !== $business->id) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
// Get products owned by this business
|
||||
$products = Product::whereHas('brand', function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
})->orderBy('name', 'asc')->get();
|
||||
|
||||
$batch->load('coaFiles');
|
||||
|
||||
return view('seller.batches.edit', compact('business', 'batch', 'products'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified batch
|
||||
*/
|
||||
public function update(Request $request, Business $business, Batch $batch)
|
||||
{
|
||||
// Verify batch belongs to this business
|
||||
if ($batch->business_id !== $business->id) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
// Determine max value based on unit (% vs mg/g, mg/ml, mg/unit)
|
||||
$maxValue = $request->cannabinoid_unit === '%' ? 100 : 1000;
|
||||
|
||||
$validated = $request->validate([
|
||||
'product_id' => 'required|exists:products,id',
|
||||
'cannabinoid_unit' => 'required|string|in:%,MG/ML,MG/G,MG/UNIT',
|
||||
'batch_number' => 'nullable|string|max:100|unique:batches,batch_number,'.$batch->id,
|
||||
'production_date' => 'nullable|date',
|
||||
'test_date' => 'nullable|date',
|
||||
'test_id' => 'nullable|string|max:100',
|
||||
'lot_number' => 'nullable|string|max:100',
|
||||
'lab_name' => 'nullable|string|max:255',
|
||||
'thc_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'thca_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'cbd_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'cbda_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'cbg_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'cbn_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'delta_9_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'total_terps_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'notes' => 'nullable|string',
|
||||
'coa_files.*' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240', // 10MB max per file
|
||||
]);
|
||||
|
||||
// Verify product belongs to this business
|
||||
$product = Product::whereHas('brand', function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
})->findOrFail($validated['product_id']);
|
||||
|
||||
// Update batch (calculations happen in model boot method)
|
||||
$batch->update($validated);
|
||||
|
||||
// Handle new COA file uploads
|
||||
if ($request->hasFile('coa_files')) {
|
||||
$existingFilesCount = $batch->coaFiles()->count();
|
||||
foreach ($request->file('coa_files') as $index => $file) {
|
||||
$storagePath = "businesses/{$business->uuid}/batches/{$batch->id}/coas";
|
||||
$fileName = uniqid().'.'.$file->getClientOriginalExtension();
|
||||
$filePath = $file->storeAs($storagePath, $fileName, 'public');
|
||||
|
||||
$batch->coaFiles()->create([
|
||||
'file_name' => $file->getClientOriginalName(),
|
||||
'file_path' => $filePath,
|
||||
'file_type' => $file->getClientOriginalExtension(),
|
||||
'file_size' => $file->getSize(),
|
||||
'is_primary' => $existingFilesCount === 0 && $index === 0,
|
||||
'display_order' => $existingFilesCount + $index,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.batches.index', $business->slug)
|
||||
->with('success', 'Batch updated successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified batch
|
||||
*/
|
||||
public function destroy(Request $request, Business $business, Batch $batch)
|
||||
{
|
||||
// Verify batch belongs to this business
|
||||
if ($batch->business_id !== $business->id) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
// Delete associated COA files from storage
|
||||
foreach ($batch->coaFiles as $coaFile) {
|
||||
if (Storage::disk('public')->exists($coaFile->file_path)) {
|
||||
Storage::disk('public')->delete($coaFile->file_path);
|
||||
}
|
||||
}
|
||||
|
||||
$batch->delete();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.batches.index', $business->slug)
|
||||
->with('success', 'Batch deleted successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate QR code for a batch
|
||||
*/
|
||||
public function generateQrCode(Request $request, Business $business, Batch $batch)
|
||||
{
|
||||
// Verify batch belongs to this business
|
||||
if ($batch->business_id !== $business->id) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
$qrService = app(QrCodeService::class);
|
||||
$result = $qrService->generateWithLogo($batch);
|
||||
|
||||
// Refresh batch to get updated qr_code_path
|
||||
$batch->refresh();
|
||||
|
||||
return response()->json([
|
||||
'success' => $result['success'],
|
||||
'message' => $result['message'],
|
||||
'qr_code_url' => $batch->qr_code_path ? Storage::url($batch->qr_code_path) : null,
|
||||
'download_url' => $batch->qr_code_path ? route('seller.business.batches.qr-code.download', [$business->slug, $batch->id]) : null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download QR code for a batch
|
||||
*/
|
||||
public function downloadQrCode(Request $request, Business $business, Batch $batch)
|
||||
{
|
||||
// Verify batch belongs to this business
|
||||
if ($batch->business_id !== $business->id) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
$qrService = app(QrCodeService::class);
|
||||
$download = $qrService->download($batch);
|
||||
|
||||
if (! $download) {
|
||||
return back()->with('error', 'QR code not found');
|
||||
}
|
||||
|
||||
return $download;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate QR code for a batch
|
||||
*/
|
||||
public function regenerateQrCode(Request $request, Business $business, Batch $batch)
|
||||
{
|
||||
// Verify batch belongs to this business
|
||||
if ($batch->business_id !== $business->id) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
$qrService = app(QrCodeService::class);
|
||||
$result = $qrService->regenerate($batch);
|
||||
|
||||
// Refresh batch to get updated qr_code_path
|
||||
$batch->refresh();
|
||||
|
||||
return response()->json([
|
||||
'success' => $result['success'],
|
||||
'message' => $result['message'],
|
||||
'qr_code_url' => $batch->qr_code_path ? Storage::url($batch->qr_code_path) : null,
|
||||
'download_url' => $batch->qr_code_path ? route('seller.business.batches.qr-code.download', [$business->slug, $batch->id]) : null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete QR code for a batch
|
||||
*/
|
||||
public function deleteQrCode(Request $request, Business $business, Batch $batch)
|
||||
{
|
||||
// Verify batch belongs to this business
|
||||
if ($batch->business_id !== $business->id) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
$qrService = app(QrCodeService::class);
|
||||
$result = $qrService->delete($batch);
|
||||
|
||||
return response()->json([
|
||||
'success' => $result['success'],
|
||||
'message' => $result['message'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk generate QR codes for multiple batches
|
||||
*/
|
||||
public function bulkGenerateQrCodes(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'batch_ids' => 'required|array',
|
||||
'batch_ids.*' => 'exists:batches,id',
|
||||
]);
|
||||
|
||||
// Verify all batches belong to this business
|
||||
$batches = Batch::whereIn('id', $validated['batch_ids'])
|
||||
->where('business_id', $business->id)
|
||||
->get();
|
||||
|
||||
if ($batches->count() !== count($validated['batch_ids'])) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Some batches do not belong to this business',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$qrService = app(QrCodeService::class);
|
||||
$result = $qrService->bulkGenerate($validated['batch_ids']);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
}
|
||||
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');
|
||||
}
|
||||
}
|
||||
100
app/Http/Controllers/Seller/ComponentCategoryController.php
Normal file
100
app/Http/Controllers/Seller/ComponentCategoryController.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\ComponentCategory;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ComponentCategoryController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of component categories
|
||||
*/
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$query = ComponentCategory::where('business_id', $business->id);
|
||||
|
||||
// Search filter
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'LIKE', "%{$search}%")
|
||||
->orWhere('description', 'LIKE', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// Sort
|
||||
$sortBy = $request->get('sort_by', 'name');
|
||||
$sortDir = $request->get('sort_dir', 'asc');
|
||||
$query->orderBy($sortBy, $sortDir);
|
||||
|
||||
$categories = $query->with('components')->paginate(20)->withQueryString();
|
||||
|
||||
return view('seller.categories.components.index', compact('business', 'categories'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created component category
|
||||
*/
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255|unique:component_categories,name,NULL,id,business_id,'.$business->id,
|
||||
'description' => 'nullable|string',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
$validated['business_id'] = $business->id;
|
||||
$validated['is_active'] = $request->has('is_active');
|
||||
|
||||
$category = ComponentCategory::create($validated);
|
||||
|
||||
return back()->with('success', "Component category '{$category->name}' created successfully!");
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified component category
|
||||
*/
|
||||
public function update(Request $request, Business $business, ComponentCategory $componentCategory)
|
||||
{
|
||||
// Verify category belongs to this business
|
||||
if ($componentCategory->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255|unique:component_categories,name,'.$componentCategory->id.',id,business_id,'.$business->id,
|
||||
'description' => 'nullable|string',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
$validated['is_active'] = $request->has('is_active');
|
||||
$componentCategory->update($validated);
|
||||
|
||||
return back()->with('success', "Component category '{$componentCategory->name}' updated successfully!");
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified component category
|
||||
*/
|
||||
public function destroy(Business $business, ComponentCategory $componentCategory)
|
||||
{
|
||||
// Verify category belongs to this business
|
||||
if ($componentCategory->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
// Check if category is in use
|
||||
$componentsCount = $componentCategory->components()->count();
|
||||
if ($componentsCount > 0) {
|
||||
return back()->with('error', "Cannot delete category '{$componentCategory->name}' because it has {$componentsCount} associated components.");
|
||||
}
|
||||
|
||||
$name = $componentCategory->name;
|
||||
$componentCategory->delete();
|
||||
|
||||
return back()->with('success', "Component category '{$name}' deleted successfully!");
|
||||
}
|
||||
}
|
||||
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'
|
||||
));
|
||||
}
|
||||
}
|
||||
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'));
|
||||
}
|
||||
}
|
||||
100
app/Http/Controllers/Seller/ProductCategoryController.php
Normal file
100
app/Http/Controllers/Seller/ProductCategoryController.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\ProductCategory;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ProductCategoryController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of product categories
|
||||
*/
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$query = ProductCategory::where('business_id', $business->id);
|
||||
|
||||
// Search filter
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'LIKE', "%{$search}%")
|
||||
->orWhere('description', 'LIKE', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// Sort
|
||||
$sortBy = $request->get('sort_by', 'name');
|
||||
$sortDir = $request->get('sort_dir', 'asc');
|
||||
$query->orderBy($sortBy, $sortDir);
|
||||
|
||||
$categories = $query->with('products')->paginate(20)->withQueryString();
|
||||
|
||||
return view('seller.categories.products.index', compact('business', 'categories'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created product category
|
||||
*/
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255|unique:product_categories,name,NULL,id,business_id,'.$business->id,
|
||||
'description' => 'nullable|string',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
$validated['business_id'] = $business->id;
|
||||
$validated['is_active'] = $request->has('is_active');
|
||||
|
||||
$category = ProductCategory::create($validated);
|
||||
|
||||
return back()->with('success', "Product category '{$category->name}' created successfully!");
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified product category
|
||||
*/
|
||||
public function update(Request $request, Business $business, ProductCategory $productCategory)
|
||||
{
|
||||
// Verify category belongs to this business
|
||||
if ($productCategory->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255|unique:product_categories,name,'.$productCategory->id.',id,business_id,'.$business->id,
|
||||
'description' => 'nullable|string',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
$validated['is_active'] = $request->has('is_active');
|
||||
$productCategory->update($validated);
|
||||
|
||||
return back()->with('success', "Product category '{$productCategory->name}' updated successfully!");
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified product category
|
||||
*/
|
||||
public function destroy(Business $business, ProductCategory $productCategory)
|
||||
{
|
||||
// Verify category belongs to this business
|
||||
if ($productCategory->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
// Check if category is in use
|
||||
$productsCount = $productCategory->products()->count();
|
||||
if ($productsCount > 0) {
|
||||
return back()->with('error', "Cannot delete category '{$productCategory->name}' because it has {$productsCount} associated products.");
|
||||
}
|
||||
|
||||
$name = $productCategory->name;
|
||||
$productCategory->delete();
|
||||
|
||||
return back()->with('success', "Product category '{$name}' deleted successfully!");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -8,6 +8,89 @@ use Illuminate\Http\Request;
|
||||
|
||||
class SettingsController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the personal profile page.
|
||||
*/
|
||||
public function profile(Business $business)
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
// Get login history (assuming a login_histories table exists)
|
||||
$loginHistory = collect(); // Placeholder - will be implemented with login history tracking
|
||||
|
||||
return view('seller.settings.profile', compact('business', 'loginHistory'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the personal profile.
|
||||
*/
|
||||
public function updateProfile(Business $business, Request $request)
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
$validated = $request->validate([
|
||||
'first_name' => 'required|string|max:255',
|
||||
'last_name' => 'required|string|max:255',
|
||||
'email' => 'required|email|max:255|unique:users,email,'.$user->id,
|
||||
'avatar' => 'nullable|image|max:2048',
|
||||
'remove_avatar' => 'nullable|boolean',
|
||||
'use_gravatar' => 'nullable|boolean',
|
||||
'linkedin_url' => 'nullable|url|max:255',
|
||||
'twitter_url' => 'nullable|url|max:255',
|
||||
'facebook_url' => 'nullable|url|max:255',
|
||||
'instagram_url' => 'nullable|url|max:255',
|
||||
'github_url' => 'nullable|url|max:255',
|
||||
]);
|
||||
|
||||
// Handle avatar removal
|
||||
if ($request->has('remove_avatar') && $user->avatar_path) {
|
||||
\Storage::disk('public')->delete($user->avatar_path);
|
||||
$validated['avatar_path'] = null;
|
||||
}
|
||||
|
||||
// Handle avatar upload
|
||||
if ($request->hasFile('avatar')) {
|
||||
// Delete old avatar if exists
|
||||
if ($user->avatar_path) {
|
||||
\Storage::disk('public')->delete($user->avatar_path);
|
||||
}
|
||||
|
||||
$path = $request->file('avatar')->store('avatars', 'public');
|
||||
$validated['avatar_path'] = $path;
|
||||
}
|
||||
|
||||
$user->update($validated);
|
||||
|
||||
return redirect()->route('seller.business.settings.profile', $business->slug)
|
||||
->with('success', 'Profile updated successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the user's password.
|
||||
*/
|
||||
public function updatePassword(Business $business, Request $request)
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
$validated = $request->validate([
|
||||
'current_password' => 'required|current_password',
|
||||
'password' => 'required|string|min:8|confirmed',
|
||||
'logout_other_sessions' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
$user->update([
|
||||
'password' => bcrypt($validated['password']),
|
||||
]);
|
||||
|
||||
// Logout other sessions if requested
|
||||
if ($request->has('logout_other_sessions')) {
|
||||
auth()->logoutOtherDevices($validated['password']);
|
||||
}
|
||||
|
||||
return redirect()->route('seller.business.settings.profile', $business->slug)
|
||||
->with('success', 'Password updated successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the company information settings page.
|
||||
*/
|
||||
@@ -18,21 +101,12 @@ class SettingsController extends Controller
|
||||
|
||||
/**
|
||||
* Update the company information.
|
||||
* Note: Only business_phone and business_email can be updated due to compliance requirements.
|
||||
*/
|
||||
public function updateCompanyInformation(Business $business, Request $request)
|
||||
{
|
||||
// Only allow updating business phone and email (hybrid approach)
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'dba_name' => 'nullable|string|max:255',
|
||||
'description' => 'nullable|string|max:1000',
|
||||
'business_type' => 'nullable|string',
|
||||
'tin_ein' => 'nullable|string|max:20',
|
||||
'license_number' => 'nullable|string|max:255',
|
||||
'license_type' => 'nullable|string',
|
||||
'physical_address' => 'nullable|string|max:255',
|
||||
'physical_city' => 'nullable|string|max:100',
|
||||
'physical_state' => 'nullable|string|max:2',
|
||||
'physical_zipcode' => 'nullable|string|max:10',
|
||||
'business_phone' => 'nullable|string|max:20',
|
||||
'business_email' => 'nullable|email|max:255',
|
||||
]);
|
||||
@@ -41,39 +115,233 @@ class SettingsController extends Controller
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.settings.company-information', $business->slug)
|
||||
->with('success', 'Company information updated successfully!');
|
||||
->with('success', 'Contact information updated successfully!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the users management settings page.
|
||||
*/
|
||||
public function users(Business $business)
|
||||
public function users(Business $business, Request $request, \App\Services\PermissionService $permissionService)
|
||||
{
|
||||
return view('seller.settings.users', compact('business'));
|
||||
// Exclude business owner from the users list
|
||||
$query = $business->users()->where('users.id', '!=', $business->owner_user_id);
|
||||
|
||||
// Search
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by account type (role)
|
||||
if ($request->filled('account_type')) {
|
||||
$query->whereHas('roles', function ($q) use ($request) {
|
||||
$q->where('name', $request->account_type);
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by last login date range
|
||||
if ($request->filled('last_login_start')) {
|
||||
$query->where('last_login_at', '>=', $request->last_login_start);
|
||||
}
|
||||
if ($request->filled('last_login_end')) {
|
||||
$query->where('last_login_at', '<=', $request->last_login_end.' 23:59:59');
|
||||
}
|
||||
|
||||
$users = $query
|
||||
->withPivot('contact_type', 'is_primary', 'permissions', 'role', 'role_template')
|
||||
->with('roles')
|
||||
->orderBy('last_name')
|
||||
->orderBy('first_name')
|
||||
->paginate(15);
|
||||
|
||||
$roleTemplates = $permissionService->getRoleTemplates();
|
||||
$permissionCategories = $permissionService->getPermissionsByCategory();
|
||||
|
||||
return view('seller.settings.users', compact('business', 'users', 'roleTemplates', 'permissionCategories'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the order settings page.
|
||||
* Show the form for editing a user's permissions.
|
||||
*/
|
||||
public function orders(Business $business)
|
||||
public function editUser(Business $business, \App\Models\User $user, \App\Services\PermissionService $permissionService)
|
||||
{
|
||||
return view('seller.settings.orders', compact('business'));
|
||||
// Check if user belongs to this business
|
||||
if (! $business->users()->where('users.id', $user->id)->exists()) {
|
||||
abort(403, 'User does not belong to this business');
|
||||
}
|
||||
|
||||
// Load user with pivot data
|
||||
$user = $business->users()
|
||||
->withPivot('contact_type', 'is_primary', 'permissions', 'role', 'role_template')
|
||||
->with('roles')
|
||||
->where('users.id', $user->id)
|
||||
->first();
|
||||
|
||||
$roleTemplates = $permissionService->getRoleTemplates();
|
||||
$permissionCategories = $permissionService->getPermissionsByCategory();
|
||||
$isOwner = $business->owner_user_id === $user->id;
|
||||
|
||||
return view('seller.settings.users-edit', compact('business', 'user', 'roleTemplates', 'permissionCategories', 'isOwner'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the brands management page.
|
||||
* Store a newly created user invitation.
|
||||
*/
|
||||
public function brands(Business $business)
|
||||
public function inviteUser(Business $business, Request $request)
|
||||
{
|
||||
return view('seller.settings.brands', compact('business'));
|
||||
$validated = $request->validate([
|
||||
'first_name' => 'required|string|max:255',
|
||||
'last_name' => 'required|string|max:255',
|
||||
'email' => 'required|email|unique:users,email',
|
||||
'phone' => 'nullable|string|max:20',
|
||||
'position' => 'nullable|string|max:255',
|
||||
'role' => 'required|string|in:company-owner,company-manager,company-user,company-sales,company-accounting,company-manufacturing,company-processing',
|
||||
'is_point_of_contact' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
// Combine first and last name
|
||||
$fullName = trim($validated['first_name'].' '.$validated['last_name']);
|
||||
|
||||
// Create user and associate with business
|
||||
$user = \App\Models\User::create([
|
||||
'name' => $fullName,
|
||||
'email' => $validated['email'],
|
||||
'phone' => $validated['phone'],
|
||||
'password' => bcrypt(str()->random(32)), // Temporary password
|
||||
]);
|
||||
|
||||
// Assign role
|
||||
$user->assignRole($validated['role']);
|
||||
|
||||
// Associate with business with additional pivot data
|
||||
$business->users()->attach($user->id, [
|
||||
'role' => $validated['role'],
|
||||
'is_primary' => false,
|
||||
'contact_type' => $request->has('is_point_of_contact') ? 'primary' : null,
|
||||
]);
|
||||
|
||||
// TODO: Send invitation email with password reset link
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.settings.users', $business->slug)
|
||||
->with('success', 'User invited successfully!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the payment settings page.
|
||||
* Update user information and permissions.
|
||||
*/
|
||||
public function payments(Business $business)
|
||||
public function updateUser(Business $business, \App\Models\User $user, Request $request)
|
||||
{
|
||||
return view('seller.settings.payments', compact('business'));
|
||||
// Check if user belongs to this business
|
||||
if (! $business->users()->where('users.id', $user->id)->exists()) {
|
||||
abort(403, 'User does not belong to this business');
|
||||
}
|
||||
|
||||
// Prevent modifying business owner
|
||||
if ($business->owner_user_id === $user->id) {
|
||||
return redirect()
|
||||
->route('seller.business.settings.users.edit', [$business->slug, $user->id])
|
||||
->with('error', 'Cannot modify business owner permissions.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'position' => 'nullable|string|max:255',
|
||||
'company' => 'nullable|string|max:255',
|
||||
'contact_type' => 'nullable|string|max:255',
|
||||
'role' => 'nullable|string|max:255',
|
||||
'role_template' => 'nullable|string|max:255',
|
||||
'permissions' => 'nullable|array',
|
||||
]);
|
||||
|
||||
// Update user data
|
||||
$user->update([
|
||||
'position' => $validated['position'] ?? null,
|
||||
'company' => $validated['company'] ?? null,
|
||||
]);
|
||||
|
||||
// Update business_user pivot data
|
||||
$business->users()->updateExistingPivot($user->id, [
|
||||
'contact_type' => $validated['contact_type'] ?? null,
|
||||
'role' => $validated['role'] ?? null,
|
||||
'role_template' => $validated['role_template'] ?? null,
|
||||
'permissions' => $validated['permissions'] ?? null,
|
||||
'permissions_updated_at' => now(),
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.settings.users.edit', [$business->slug, $user->id])
|
||||
->with('success', 'User updated successfully!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove user from business.
|
||||
*/
|
||||
public function removeUser(Business $business, \App\Models\User $user)
|
||||
{
|
||||
// Check if user belongs to this business
|
||||
if (! $business->users()->where('users.id', $user->id)->exists()) {
|
||||
abort(403, 'User does not belong to this business');
|
||||
}
|
||||
|
||||
// Detach user from business
|
||||
$business->users()->detach($user->id);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.settings.users', $business->slug)
|
||||
->with('success', 'User removed successfully!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the sales configuration page (orders + invoices).
|
||||
*/
|
||||
public function salesConfig(Business $business)
|
||||
{
|
||||
return view('seller.settings.sales-config', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the sales configuration settings (orders + invoices).
|
||||
*/
|
||||
public function updateSalesConfig(Business $business, Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
// Order settings
|
||||
'separate_orders_by_brand' => 'nullable|boolean',
|
||||
'auto_increment_order_ids' => 'nullable|boolean',
|
||||
'show_mark_as_paid' => 'nullable|boolean',
|
||||
'display_crm_license_on_orders' => 'nullable|boolean',
|
||||
'order_minimum' => 'nullable|numeric|min:0',
|
||||
'default_shipping_charge' => 'nullable|numeric|min:0',
|
||||
'free_shipping_minimum' => 'nullable|numeric|min:0',
|
||||
'order_disclaimer' => 'nullable|string|max:2000',
|
||||
'order_invoice_footer' => 'nullable|string|max:1000',
|
||||
'prevent_order_editing' => 'required|in:never,after_approval,after_fulfillment,always',
|
||||
'az_require_patient_count' => 'nullable|boolean',
|
||||
'az_require_allotment_verification' => 'nullable|boolean',
|
||||
// Invoice settings
|
||||
'invoice_payable_company_name' => 'nullable|string|max:255',
|
||||
'invoice_payable_address' => 'nullable|string|max:255',
|
||||
'invoice_payable_city' => 'nullable|string|max:100',
|
||||
'invoice_payable_state' => 'nullable|string|max:2',
|
||||
'invoice_payable_zipcode' => 'nullable|string|max:10',
|
||||
]);
|
||||
|
||||
// Convert checkbox values (null means unchecked)
|
||||
$validated['separate_orders_by_brand'] = $request->has('separate_orders_by_brand');
|
||||
$validated['auto_increment_order_ids'] = $request->has('auto_increment_order_ids');
|
||||
$validated['show_mark_as_paid'] = $request->has('show_mark_as_paid');
|
||||
$validated['display_crm_license_on_orders'] = $request->has('display_crm_license_on_orders');
|
||||
$validated['az_require_patient_count'] = $request->has('az_require_patient_count');
|
||||
$validated['az_require_allotment_verification'] = $request->has('az_require_allotment_verification');
|
||||
|
||||
$business->update($validated);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.settings.sales-config', $business->slug)
|
||||
->with('success', 'Sales configuration updated successfully!');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,12 +352,32 @@ class SettingsController extends Controller
|
||||
return view('seller.settings.invoices', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the brand kit page (Cannabrands assets/branding settings).
|
||||
*/
|
||||
public function brandKit(Business $business)
|
||||
{
|
||||
return view('seller.settings.brand-kit', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the payment settings page.
|
||||
*/
|
||||
public function payments(Business $business)
|
||||
{
|
||||
return view('seller.settings.payments', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the manage licenses page.
|
||||
*/
|
||||
public function manageLicenses(Business $business)
|
||||
{
|
||||
return view('seller.settings.manage-licenses', compact('business'));
|
||||
// TODO: License table is currently a placeholder - needs migration update
|
||||
// For now, return empty collection so the UI displays properly
|
||||
$licenses = collect([]);
|
||||
|
||||
return view('seller.settings.manage-licenses', compact('business', 'licenses'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -100,6 +388,150 @@ class SettingsController extends Controller
|
||||
return view('seller.settings.plans-and-billing', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the business subscription plan.
|
||||
*/
|
||||
public function changePlan(Business $business, Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'plan_id' => 'required|in:standard,business,premium',
|
||||
]);
|
||||
|
||||
$planId = $validated['plan_id'];
|
||||
|
||||
// Define available plans with pricing
|
||||
$plans = [
|
||||
'standard' => ['name' => 'Marketplace Standard', 'price' => 99.00],
|
||||
'business' => ['name' => 'Marketplace Business', 'price' => 395.00],
|
||||
'premium' => ['name' => 'Marketplace Premium', 'price' => 795.00],
|
||||
];
|
||||
|
||||
$newPlan = $plans[$planId];
|
||||
|
||||
// Get or create subscription
|
||||
$subscription = $business->subscription()->firstOrCreate(
|
||||
['business_id' => $business->id],
|
||||
[
|
||||
'plan_id' => 'standard',
|
||||
'plan_name' => 'Marketplace Standard',
|
||||
'plan_price' => 99.00,
|
||||
'status' => 'active',
|
||||
'current_period_start' => now(),
|
||||
'current_period_end' => now()->addMonth(),
|
||||
]
|
||||
);
|
||||
|
||||
// Check if same plan
|
||||
if ($subscription->plan_id === $planId) {
|
||||
return redirect()
|
||||
->route('seller.business.settings.plans-and-billing', $business->slug)
|
||||
->with('info', 'You are already on this plan.');
|
||||
}
|
||||
|
||||
// Determine if upgrade or downgrade
|
||||
$isUpgrade = $newPlan['price'] > $subscription->plan_price;
|
||||
|
||||
if ($isUpgrade) {
|
||||
// UPGRADE: Calculate prorated charge and update immediately
|
||||
$daysLeftInCycle = now()->diffInDays($subscription->current_period_end);
|
||||
$proratedCredit = ($subscription->plan_price / 30) * $daysLeftInCycle;
|
||||
$proratedCharge = ($newPlan['price'] / 30) * $daysLeftInCycle;
|
||||
$amountToPay = $proratedCharge - $proratedCredit;
|
||||
|
||||
// Create invoice for the upgrade
|
||||
$invoiceNumber = 'INV-'.now()->format('Y').'-'.str_pad(\App\Models\SubscriptionInvoice::count() + 1, 5, '0', STR_PAD_LEFT);
|
||||
|
||||
$invoice = \App\Models\SubscriptionInvoice::create([
|
||||
'subscription_id' => $subscription->id,
|
||||
'business_id' => $business->id,
|
||||
'invoice_number' => $invoiceNumber,
|
||||
'type' => 'upgrade',
|
||||
'amount' => $amountToPay,
|
||||
'status' => 'pending',
|
||||
'invoice_date' => now(),
|
||||
'due_date' => now()->addDays(7),
|
||||
'line_items' => [
|
||||
[
|
||||
'description' => "{$newPlan['name']} (prorated for {$daysLeftInCycle} days)",
|
||||
'amount' => $proratedCharge,
|
||||
],
|
||||
[
|
||||
'description' => "Credit from {$subscription->plan_name}",
|
||||
'amount' => -$proratedCredit,
|
||||
],
|
||||
],
|
||||
'payment_method_id' => $subscription->default_payment_method_id,
|
||||
]);
|
||||
|
||||
// Update subscription to new plan immediately
|
||||
$subscription->update([
|
||||
'plan_id' => $planId,
|
||||
'plan_name' => $newPlan['name'],
|
||||
'plan_price' => $newPlan['price'],
|
||||
'scheduled_plan_id' => null,
|
||||
'scheduled_plan_name' => null,
|
||||
'scheduled_plan_price' => null,
|
||||
'scheduled_change_date' => null,
|
||||
]);
|
||||
|
||||
// TODO: Charge the payment method for $amountToPay
|
||||
// TODO: Mark invoice as paid after successful charge
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.settings.plans-and-billing', $business->slug)
|
||||
->with('success', sprintf(
|
||||
'Plan upgraded to %s! Invoice %s created for $%s (prorated). New features are active immediately.',
|
||||
$newPlan['name'],
|
||||
$invoiceNumber,
|
||||
number_format($amountToPay, 2)
|
||||
));
|
||||
|
||||
} else {
|
||||
// DOWNGRADE: Schedule for next billing cycle
|
||||
$subscription->update([
|
||||
'scheduled_plan_id' => $planId,
|
||||
'scheduled_plan_name' => $newPlan['name'],
|
||||
'scheduled_plan_price' => $newPlan['price'],
|
||||
'scheduled_change_date' => $subscription->current_period_end,
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.settings.plans-and-billing', $business->slug)
|
||||
->with('info', sprintf(
|
||||
'Plan will be downgraded to %s on %s. You\'ll continue to have access to %s features until then.',
|
||||
$newPlan['name'],
|
||||
$subscription->current_period_end->format('F j, Y'),
|
||||
$subscription->plan_name
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a scheduled plan downgrade.
|
||||
*/
|
||||
public function cancelDowngrade(Business $business)
|
||||
{
|
||||
$subscription = $business->subscription;
|
||||
|
||||
if (! $subscription || ! $subscription->hasScheduledDowngrade()) {
|
||||
return redirect()
|
||||
->route('seller.business.settings.plans-and-billing', $business->slug)
|
||||
->with('error', 'No scheduled downgrade found.');
|
||||
}
|
||||
|
||||
// Cancel the scheduled downgrade
|
||||
$subscription->update([
|
||||
'scheduled_plan_id' => null,
|
||||
'scheduled_plan_name' => null,
|
||||
'scheduled_plan_price' => null,
|
||||
'scheduled_change_date' => null,
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.settings.plans-and-billing', $business->slug)
|
||||
->with('success', 'Scheduled plan downgrade has been cancelled. You will remain on your current plan.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the notification preferences page.
|
||||
*/
|
||||
@@ -108,6 +540,65 @@ class SettingsController extends Controller
|
||||
return view('seller.settings.notifications', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the notification settings.
|
||||
*
|
||||
* EMAIL NOTIFICATION RULES DOCUMENTATION:
|
||||
*
|
||||
* 1. NEW ORDER EMAIL NOTIFICATIONS (new_order_email_notifications)
|
||||
* Base: Email these addresses when a new order is placed
|
||||
* - If 'new_order_only_when_no_sales_rep' checked: ONLY send if buyer has NO sales rep assigned
|
||||
* - If 'new_order_do_not_send_to_admins' checked: Do NOT send to company admins (only to these addresses)
|
||||
*
|
||||
* 2. ORDER ACCEPTED EMAIL NOTIFICATIONS (order_accepted_email_notifications)
|
||||
* Base: Email these addresses when an order is accepted
|
||||
* - If 'enable_shipped_emails_for_sales_reps' checked: Sales reps assigned to customer get email when order marked Shipped
|
||||
*
|
||||
* 3. PLATFORM INQUIRY EMAIL NOTIFICATIONS (platform_inquiry_email_notifications)
|
||||
* Base: Email these addresses for inquiries
|
||||
* - Sales reps associated with customer ALWAYS receive email
|
||||
* - If field is blank AND no sales reps exist: company admins receive notifications
|
||||
*
|
||||
* 4. MANUAL ORDER EMAIL NOTIFICATIONS
|
||||
* - If 'enable_manual_order_email_notifications' checked: Send same emails for manual orders as buyer-created orders
|
||||
* - If 'enable_manual_order_email_notifications' unchecked: Only send for buyer-created orders
|
||||
* - If 'manual_order_emails_internal_only' checked: Send manual order emails to internal recipients only (not buyers)
|
||||
*
|
||||
* 5. LOW INVENTORY EMAIL NOTIFICATIONS (low_inventory_email_notifications)
|
||||
* Base: Email these addresses when inventory is low
|
||||
*
|
||||
* 6. CERTIFIED SELLER STATUS EMAIL NOTIFICATIONS (certified_seller_status_email_notifications)
|
||||
* Base: Email these addresses when seller status changes
|
||||
*/
|
||||
public function updateNotifications(Business $business, Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'new_order_email_notifications' => ['nullable', 'string', new \App\Rules\CommaSeparatedEmails],
|
||||
'new_order_only_when_no_sales_rep' => 'nullable|boolean',
|
||||
'new_order_do_not_send_to_admins' => 'nullable|boolean',
|
||||
'order_accepted_email_notifications' => ['nullable', 'string', new \App\Rules\CommaSeparatedEmails],
|
||||
'enable_shipped_emails_for_sales_reps' => 'nullable|boolean',
|
||||
'platform_inquiry_email_notifications' => ['nullable', 'string', new \App\Rules\CommaSeparatedEmails],
|
||||
'enable_manual_order_email_notifications' => 'nullable|boolean',
|
||||
'manual_order_emails_internal_only' => 'nullable|boolean',
|
||||
'low_inventory_email_notifications' => ['nullable', 'string', new \App\Rules\CommaSeparatedEmails],
|
||||
'certified_seller_status_email_notifications' => ['nullable', 'string', new \App\Rules\CommaSeparatedEmails],
|
||||
]);
|
||||
|
||||
// Convert checkbox values (null means unchecked)
|
||||
$validated['new_order_only_when_no_sales_rep'] = $request->has('new_order_only_when_no_sales_rep');
|
||||
$validated['new_order_do_not_send_to_admins'] = $request->has('new_order_do_not_send_to_admins');
|
||||
$validated['enable_shipped_emails_for_sales_reps'] = $request->has('enable_shipped_emails_for_sales_reps');
|
||||
$validated['enable_manual_order_email_notifications'] = $request->has('enable_manual_order_email_notifications');
|
||||
$validated['manual_order_emails_internal_only'] = $request->has('manual_order_emails_internal_only');
|
||||
|
||||
$business->update($validated);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.settings.notifications', $business->slug)
|
||||
->with('success', 'Notification settings updated successfully!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the report settings page.
|
||||
*/
|
||||
@@ -115,4 +606,157 @@ class SettingsController extends Controller
|
||||
{
|
||||
return view('seller.settings.reports', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the integrations page.
|
||||
*/
|
||||
public function integrations(Business $business)
|
||||
{
|
||||
return view('seller.settings.integrations', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the webhooks / API page.
|
||||
*/
|
||||
public function webhooks(Business $business)
|
||||
{
|
||||
return view('seller.settings.webhooks', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the audit logs page.
|
||||
*/
|
||||
public function auditLogs(Business $business, Request $request)
|
||||
{
|
||||
// CRITICAL: Only show audit logs for THIS business (multi-tenancy)
|
||||
$query = \App\Models\AuditLog::forBusiness($business->id)
|
||||
->with(['user', 'auditable']);
|
||||
|
||||
// Search filter
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('description', 'like', "%{$search}%")
|
||||
->orWhere('event', 'like', "%{$search}%")
|
||||
->orWhereHas('user', function ($userQuery) use ($search) {
|
||||
$userQuery->where('name', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by event type
|
||||
if ($request->filled('event')) {
|
||||
$query->byEvent($request->event);
|
||||
}
|
||||
|
||||
// Filter by auditable type (resource type)
|
||||
if ($request->filled('type')) {
|
||||
$query->byType($request->type);
|
||||
}
|
||||
|
||||
// Filter by user
|
||||
if ($request->filled('user_id')) {
|
||||
$query->forUser($request->user_id);
|
||||
}
|
||||
|
||||
// Filter by date range
|
||||
if ($request->filled('start_date')) {
|
||||
$query->where('created_at', '>=', $request->start_date);
|
||||
}
|
||||
if ($request->filled('end_date')) {
|
||||
$query->where('created_at', '<=', $request->end_date.' 23:59:59');
|
||||
}
|
||||
|
||||
// Get paginated results, ordered by most recent first
|
||||
$audits = $query->latest('created_at')->paginate(50);
|
||||
|
||||
// Get unique event types for filter dropdown
|
||||
$eventTypes = \App\Models\AuditLog::forBusiness($business->id)
|
||||
->select('event')
|
||||
->distinct()
|
||||
->pluck('event')
|
||||
->sort();
|
||||
|
||||
// Get unique auditable types for filter dropdown
|
||||
$auditableTypes = \App\Models\AuditLog::forBusiness($business->id)
|
||||
->select('auditable_type')
|
||||
->whereNotNull('auditable_type')
|
||||
->distinct()
|
||||
->get()
|
||||
->map(function ($log) {
|
||||
$parts = explode('\\', $log->auditable_type);
|
||||
|
||||
return end($parts);
|
||||
})
|
||||
->unique()
|
||||
->sort();
|
||||
|
||||
return view('seller.settings.audit-logs', compact('business', 'audits', 'eventTypes', 'auditableTypes'));
|
||||
}
|
||||
|
||||
/**
|
||||
* View an invoice.
|
||||
*/
|
||||
public function viewInvoice(Business $business, string $invoiceId)
|
||||
{
|
||||
// TODO: Fetch actual invoice from database
|
||||
$invoice = [
|
||||
'id' => $invoiceId,
|
||||
'date' => now()->subDays(rand(1, 90)),
|
||||
'amount' => 395.00,
|
||||
'status' => 'paid',
|
||||
'items' => [
|
||||
['description' => 'Marketplace Business Plan', 'quantity' => 1, 'price' => 395.00],
|
||||
],
|
||||
];
|
||||
|
||||
return view('seller.settings.invoice-view', compact('business', 'invoice'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Download an invoice as PDF.
|
||||
*/
|
||||
public function downloadInvoice(Business $business, string $invoiceId)
|
||||
{
|
||||
// TODO: Generate actual PDF from invoice data
|
||||
// For now, return a mock PDF
|
||||
|
||||
$invoice = [
|
||||
'id' => $invoiceId,
|
||||
'date' => now()->subDays(rand(1, 90)),
|
||||
'amount' => 395.00,
|
||||
'status' => 'paid',
|
||||
'business_name' => $business->name,
|
||||
'business_address' => $business->physical_address,
|
||||
];
|
||||
|
||||
// Generate a simple mock PDF content
|
||||
$pdfContent = "INVOICE #{$invoice['id']}\n\n";
|
||||
$pdfContent .= "Date: {$invoice['date']->format('m/d/Y')}\n";
|
||||
$pdfContent .= "Business: {$invoice['business_name']}\n";
|
||||
$pdfContent .= 'Amount: $'.number_format($invoice['amount'], 2)."\n";
|
||||
$pdfContent .= 'Status: '.strtoupper($invoice['status'])."\n\n";
|
||||
$pdfContent .= "This is a mock invoice for testing purposes.\n";
|
||||
$pdfContent .= "In production, this would be a properly formatted PDF.\n";
|
||||
|
||||
return response($pdfContent, 200, [
|
||||
'Content-Type' => 'application/pdf',
|
||||
'Content-Disposition' => 'attachment; filename="invoice-'.$invoiceId.'.pdf"',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the current view (sales, manufacturing, compliance).
|
||||
*/
|
||||
public function switchView(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'view' => 'required|in:sales,manufacturing,compliance',
|
||||
]);
|
||||
|
||||
session(['current_view' => $validated['view']]);
|
||||
|
||||
return redirect()->back()->with('success', 'View switched successfully');
|
||||
}
|
||||
}
|
||||
|
||||
231
app/Http/Controllers/Seller/WashReportController.php
Normal file
231
app/Http/Controllers/Seller/WashReportController.php
Normal file
@@ -0,0 +1,231 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Batch;
|
||||
use App\Models\Business;
|
||||
use App\Models\Conversion;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class WashReportController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of wash reports
|
||||
*/
|
||||
public function index(Business $business)
|
||||
{
|
||||
$conversions = Conversion::where('business_id', $business->id)
|
||||
->where('conversion_type', 'hash_wash')
|
||||
->with(['operator', 'inputBatches', 'batchCreated'])
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate(20);
|
||||
|
||||
return view('seller.wash-reports.index', compact('business', 'conversions'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show Stage 1 form (wash parameters)
|
||||
*/
|
||||
public function createStage1(Business $business)
|
||||
{
|
||||
// Get available Fresh Frozen input material batches for this business
|
||||
$inputBatches = Batch::where('business_id', $business->id)
|
||||
->where('quantity_remaining', '>', 0)
|
||||
->where('is_tested', true)
|
||||
->where('is_quarantined', false)
|
||||
->orderBy('batch_number')
|
||||
->get();
|
||||
|
||||
return view('seller.wash-reports.stage1', compact('business', 'inputBatches'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store Stage 1 data and redirect to Stage 2
|
||||
*/
|
||||
public function storeStage1(Business $business, Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'wash_date' => 'nullable|date',
|
||||
'input_batch_id' => 'nullable|exists:batches,id',
|
||||
'starting_weight' => 'nullable|numeric|min:0',
|
||||
'soak_time_minutes' => 'nullable|integer|min:0',
|
||||
'room_temperature_f' => 'nullable|numeric',
|
||||
'vessel_temperature_f' => 'nullable|numeric',
|
||||
'strain' => 'nullable|string|max:255',
|
||||
'wash_cycles' => 'nullable|array',
|
||||
'wash_cycles.*.cycle' => 'nullable|integer|min:1',
|
||||
'wash_cycles.*.forward_speed' => 'nullable|integer|min:1|max:10',
|
||||
'wash_cycles.*.reverse_speed' => 'nullable|integer|min:1|max:10',
|
||||
'wash_cycles.*.pause' => 'nullable|integer|min:0',
|
||||
'wash_cycles.*.run_time' => 'nullable|integer|min:1',
|
||||
'notes' => 'nullable|string',
|
||||
]);
|
||||
|
||||
// Verify batch belongs to business (skip validation if testing)
|
||||
$inputBatch = null;
|
||||
if (isset($validated['input_batch_id'])) {
|
||||
$inputBatch = Batch::where('business_id', $business->id)
|
||||
->where('id', $validated['input_batch_id'])
|
||||
->first();
|
||||
|
||||
// Verify sufficient quantity available
|
||||
if ($inputBatch && isset($validated['starting_weight']) && $inputBatch->quantity_remaining < $validated['starting_weight']) {
|
||||
return back()->withErrors([
|
||||
'starting_weight' => 'Insufficient quantity available. Only '.$inputBatch->quantity_remaining.'g available.',
|
||||
])->withInput();
|
||||
}
|
||||
}
|
||||
|
||||
// Create conversion with Stage 1 data
|
||||
$strain = ! empty($validated['strain']) ? $validated['strain'] : 'Test';
|
||||
$internalName = $strain.' Hash Wash #'.now()->format('Ymd-His');
|
||||
|
||||
$conversion = Conversion::create([
|
||||
'business_id' => $business->id,
|
||||
'conversion_type' => 'hash_wash',
|
||||
'status' => 'in_progress',
|
||||
'internal_name' => $internalName,
|
||||
'started_at' => ! empty($validated['wash_date']) ? $validated['wash_date'] : now(),
|
||||
'operator_user_id' => auth()->id(),
|
||||
'metadata' => [
|
||||
'stage_1' => [
|
||||
'wash_date' => $validated['wash_date'] ?? null,
|
||||
'cultivator' => ($inputBatch && isset($inputBatch->cultivator)) ? $inputBatch->cultivator : 'Unknown',
|
||||
'starting_weight' => isset($validated['starting_weight']) ? (float) $validated['starting_weight'] : 0,
|
||||
'soak_time_minutes' => isset($validated['soak_time_minutes']) ? (int) $validated['soak_time_minutes'] : 0,
|
||||
'room_temperature_f' => isset($validated['room_temperature_f']) ? (float) $validated['room_temperature_f'] : 0,
|
||||
'vessel_temperature_f' => isset($validated['vessel_temperature_f']) ? (float) $validated['vessel_temperature_f'] : 0,
|
||||
'strain' => $strain,
|
||||
'wash_cycles' => $validated['wash_cycles'] ?? [],
|
||||
],
|
||||
],
|
||||
'notes' => $validated['notes'] ?? null,
|
||||
]);
|
||||
|
||||
// Link input batch to conversion (if batch was selected)
|
||||
if ($inputBatch) {
|
||||
$conversion->inputBatches()->attach($inputBatch->id, [
|
||||
'role' => 'input',
|
||||
'quantity_used' => $validated['starting_weight'] ?? 0,
|
||||
'unit' => 'g',
|
||||
]);
|
||||
}
|
||||
|
||||
// Redirect to Stage 2
|
||||
return redirect()->route('seller.business.wash-reports.stage2', [
|
||||
'business' => $business->slug,
|
||||
'conversion' => $conversion->id,
|
||||
])->with('success', 'Stage 1 completed. Now enter yield details.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show Stage 2 form (yield tracking)
|
||||
*/
|
||||
public function createStage2(Business $business, Conversion $conversion)
|
||||
{
|
||||
// Verify conversion belongs to business
|
||||
if ($conversion->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
// Verify Stage 1 is complete
|
||||
if (! $conversion->getStage1Data()) {
|
||||
return redirect()->route('seller.business.wash-reports.stage1', $business->slug)
|
||||
->withErrors(['error' => 'Please complete Stage 1 first.']);
|
||||
}
|
||||
|
||||
$stage1Data = $conversion->getStage1Data();
|
||||
|
||||
return view('seller.wash-reports.stage2', compact('business', 'conversion', 'stage1Data'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store Stage 2 data and complete conversion
|
||||
*/
|
||||
public function storeStage2(Business $business, Conversion $conversion, Request $request)
|
||||
{
|
||||
// Verify conversion belongs to business
|
||||
if ($conversion->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'fresh_press_120u' => 'required|numeric|min:0',
|
||||
'cold_cure_90u' => 'required|numeric|min:0',
|
||||
'rosin_45u' => 'required|numeric|min:0',
|
||||
'green_blonde_160u' => 'required|numeric|min:0',
|
||||
'green_blonde_25u' => 'required|numeric|min:0',
|
||||
]);
|
||||
|
||||
$stage1Data = $conversion->getStage1Data();
|
||||
$startingWeight = $stage1Data['starting_weight'];
|
||||
|
||||
// Calculate individual percentages
|
||||
$freshPressPercentage = $startingWeight > 0 ? round(($validated['fresh_press_120u'] / $startingWeight) * 100, 2) : 0;
|
||||
$coldCurePercentage = $startingWeight > 0 ? round(($validated['cold_cure_90u'] / $startingWeight) * 100, 2) : 0;
|
||||
$rosinPercentage = $startingWeight > 0 ? round(($validated['rosin_45u'] / $startingWeight) * 100, 2) : 0;
|
||||
$greenBlonde160Percentage = $startingWeight > 0 ? round(($validated['green_blonde_160u'] / $startingWeight) * 100, 2) : 0;
|
||||
$greenBlonde25Percentage = $startingWeight > 0 ? round(($validated['green_blonde_25u'] / $startingWeight) * 100, 2) : 0;
|
||||
|
||||
// Calculate total yield
|
||||
$totalYield = $validated['fresh_press_120u']
|
||||
+ $validated['cold_cure_90u']
|
||||
+ $validated['rosin_45u']
|
||||
+ $validated['green_blonde_160u']
|
||||
+ $validated['green_blonde_25u'];
|
||||
|
||||
// Update conversion with Stage 2 data
|
||||
$metadata = $conversion->metadata;
|
||||
$metadata['stage_2'] = [
|
||||
'yields' => [
|
||||
'fresh_press_120u' => [
|
||||
'weight' => (float) $validated['fresh_press_120u'],
|
||||
'percentage' => $freshPressPercentage,
|
||||
],
|
||||
'cold_cure_90u' => [
|
||||
'weight' => (float) $validated['cold_cure_90u'],
|
||||
'percentage' => $coldCurePercentage,
|
||||
],
|
||||
'rosin_45u' => [
|
||||
'weight' => (float) $validated['rosin_45u'],
|
||||
'percentage' => $rosinPercentage,
|
||||
],
|
||||
'green_blonde_160u' => [
|
||||
'weight' => (float) $validated['green_blonde_160u'],
|
||||
'percentage' => $greenBlonde160Percentage,
|
||||
],
|
||||
'green_blonde_25u' => [
|
||||
'weight' => (float) $validated['green_blonde_25u'],
|
||||
'percentage' => $greenBlonde25Percentage,
|
||||
],
|
||||
],
|
||||
'total_yield' => $totalYield,
|
||||
];
|
||||
|
||||
$conversion->metadata = $metadata;
|
||||
$conversion->actual_output_quantity = $totalYield;
|
||||
$conversion->actual_output_unit = 'g';
|
||||
$conversion->save();
|
||||
|
||||
return redirect()->route('seller.business.wash-reports.show', [
|
||||
'business' => $business->slug,
|
||||
'conversion' => $conversion->id,
|
||||
])->with('success', 'Wash report completed successfully! Total yield: '.$totalYield.'g ('.$conversion->getYieldPercentage().'%)');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a single wash report
|
||||
*/
|
||||
public function show(Business $business, Conversion $conversion)
|
||||
{
|
||||
// Verify conversion belongs to business
|
||||
if ($conversion->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$conversion->load(['operator', 'inputBatches', 'batchCreated']);
|
||||
|
||||
return view('seller.wash-reports.show', compact('business', 'conversion'));
|
||||
}
|
||||
}
|
||||
@@ -10,19 +10,10 @@ class SettingsController extends Controller
|
||||
/**
|
||||
* Display the user's account settings.
|
||||
*/
|
||||
public function index()
|
||||
public function index(Business $business)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
// Get businesses owned by this user
|
||||
$ownedBusinesses = Business::where('owner_user_id', $user->id)->get();
|
||||
|
||||
// Get eligible owners for each owned business
|
||||
$eligibleOwners = [];
|
||||
foreach ($ownedBusinesses as $business) {
|
||||
$eligibleOwners[$business->id] = $business->getEligibleOwners();
|
||||
}
|
||||
|
||||
return view('settings.index', compact('user', 'ownedBusinesses', 'eligibleOwners'));
|
||||
return view('settings.index', compact('user', 'business'));
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
77
app/Jobs/LogAuditEntry.php
Normal file
77
app/Jobs/LogAuditEntry.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Async Audit Log Job
|
||||
*
|
||||
* Writes audit log entries to database via Horizon queue.
|
||||
* This prevents performance impact on user-facing requests.
|
||||
*
|
||||
* Environments:
|
||||
* - local: Job won't be dispatched (AuditLogger returns early)
|
||||
* - dev/production: Processed via Horizon
|
||||
*/
|
||||
class LogAuditEntry implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* The number of times the job may be attempted.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
public $tries = 3;
|
||||
|
||||
/**
|
||||
* The number of seconds to wait before retrying the job.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
public $backoff = 10;
|
||||
|
||||
/**
|
||||
* Audit log data
|
||||
*/
|
||||
protected array $data;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(array $data)
|
||||
{
|
||||
$this->data = $data;
|
||||
|
||||
// Use 'audit' queue for better organization in Horizon
|
||||
$this->onQueue('audit');
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
// Create the audit log entry
|
||||
AuditLog::create($this->data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a job failure.
|
||||
*/
|
||||
public function failed(\Throwable $exception): void
|
||||
{
|
||||
// Log the failure (but don't throw exception to prevent infinite retries)
|
||||
\Log::error('Failed to create audit log entry', [
|
||||
'data' => $this->data,
|
||||
'exception' => $exception->getMessage(),
|
||||
'trace' => $exception->getTraceAsString(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
42
app/Jobs/Marketing/ProcessScheduledBroadcastsJob.php
Normal file
42
app/Jobs/Marketing/ProcessScheduledBroadcastsJob.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Marketing;
|
||||
|
||||
use App\Models\Broadcast;
|
||||
use App\Services\Marketing\BroadcastService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ProcessScheduledBroadcastsJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function handle(BroadcastService $service): void
|
||||
{
|
||||
$broadcasts = Broadcast::scheduled()->get();
|
||||
|
||||
Log::info('Processing scheduled broadcasts', [
|
||||
'count' => $broadcasts->count(),
|
||||
]);
|
||||
|
||||
foreach ($broadcasts as $broadcast) {
|
||||
try {
|
||||
$service->sendBroadcast($broadcast);
|
||||
|
||||
Log::info('Scheduled broadcast started', [
|
||||
'broadcast_id' => $broadcast->id,
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to start scheduled broadcast', [
|
||||
'broadcast_id' => $broadcast->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
23
app/Mail/BroadcastEmail.php
Normal file
23
app/Mail/BroadcastEmail.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class BroadcastEmail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public string $emailSubject,
|
||||
public string $emailBody
|
||||
) {}
|
||||
|
||||
public function build()
|
||||
{
|
||||
return $this->subject($this->emailSubject)
|
||||
->html($this->emailBody);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
70
app/Models/AuditLog.php
Normal file
70
app/Models/AuditLog.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use OwenIt\Auditing\Models\Audit;
|
||||
|
||||
/**
|
||||
* AuditLog Model
|
||||
*
|
||||
* Wrapper around the Laravel Auditing package's Audit model
|
||||
* with business-specific scopes and relationships for multi-tenancy.
|
||||
*/
|
||||
class AuditLog extends Audit
|
||||
{
|
||||
/**
|
||||
* Scope to filter audits for a specific business
|
||||
*
|
||||
* Since the audits table doesn't have a business_id column,
|
||||
* we filter by the auditable models that belong to the business.
|
||||
* For now, we'll show all audits - this can be refined later
|
||||
* when implementing proper multi-tenant audit filtering.
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Builder $query
|
||||
* @param int $businessId
|
||||
* @return \Illuminate\Database\Eloquent\Builder
|
||||
*/
|
||||
public function scopeForBusiness($query, $businessId)
|
||||
{
|
||||
// TODO: Implement proper business-scoped filtering
|
||||
// This would require joining with auditable models to check business ownership
|
||||
// For now, return all audits (will be implemented when audit system is fully configured)
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to filter by event type
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Builder $query
|
||||
* @param string $event
|
||||
* @return \Illuminate\Database\Eloquent\Builder
|
||||
*/
|
||||
public function scopeByEvent($query, $event)
|
||||
{
|
||||
return $query->where('event', $event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to filter by auditable type (resource type)
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Builder $query
|
||||
* @param string $type
|
||||
* @return \Illuminate\Database\Eloquent\Builder
|
||||
*/
|
||||
public function scopeByType($query, $type)
|
||||
{
|
||||
return $query->where('auditable_type', $type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to filter audits for a specific user
|
||||
*
|
||||
* @param \Illuminate\Database\Eloquent\Builder $query
|
||||
* @param int $userId
|
||||
* @return \Illuminate\Database\Eloquent\Builder
|
||||
*/
|
||||
public function scopeForUser($query, $userId)
|
||||
{
|
||||
return $query->where('user_id', $userId);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\BelongsToBusinessDirectly;
|
||||
use App\Traits\HasHashid;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -12,7 +13,7 @@ use Str;
|
||||
|
||||
class Brand extends Model
|
||||
{
|
||||
use BelongsToBusinessDirectly, HasFactory, SoftDeletes;
|
||||
use BelongsToBusinessDirectly, HasFactory, HasHashid, SoftDeletes;
|
||||
|
||||
// Product Categories that can be organized under brands
|
||||
public const PRODUCT_CATEGORIES = [
|
||||
@@ -32,21 +33,33 @@ class Brand extends Model
|
||||
'business_id',
|
||||
|
||||
// Brand Identity
|
||||
'hashid',
|
||||
'name',
|
||||
'slug',
|
||||
'sku_prefix', // SKU prefix for products
|
||||
'description',
|
||||
'long_description',
|
||||
'tagline',
|
||||
|
||||
// Branding Assets
|
||||
'logo_path',
|
||||
'banner_path',
|
||||
'website_url',
|
||||
'colors', // JSON: hex color codes for theming
|
||||
|
||||
// Physical Address
|
||||
'address',
|
||||
'unit_number',
|
||||
'city',
|
||||
'state',
|
||||
'zip_code',
|
||||
'phone',
|
||||
|
||||
// Social Media
|
||||
'instagram_handle',
|
||||
'facebook_url',
|
||||
'twitter_handle',
|
||||
'youtube_url',
|
||||
|
||||
// Display Settings
|
||||
'is_active',
|
||||
@@ -162,14 +175,6 @@ class Brand extends Model
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get route key (slug for URLs)
|
||||
*/
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
return 'slug';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate slug from name
|
||||
*/
|
||||
@@ -178,6 +183,15 @@ class Brand extends Model
|
||||
return Str::slug($this->name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the storage path for this brand's assets
|
||||
* Format: {hashid}/ (e.g., "52kn5/")
|
||||
*/
|
||||
public function getStoragePath(): string
|
||||
{
|
||||
return $this->hashid.'/';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if brand has a logo
|
||||
*/
|
||||
|
||||
114
app/Models/BrandKit.php
Normal file
114
app/Models/BrandKit.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
class BrandKit extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
// Owner (Business or Brand)
|
||||
'owner_id',
|
||||
'owner_type',
|
||||
|
||||
// Basic Info
|
||||
'name',
|
||||
'description',
|
||||
|
||||
// Logo Assets
|
||||
'logo_primary_path',
|
||||
'logo_secondary_path',
|
||||
'logo_icon_path',
|
||||
'logo_white_path',
|
||||
'logo_black_path',
|
||||
|
||||
// Brand Guidelines
|
||||
'colors', // JSON
|
||||
'fonts', // JSON
|
||||
'voice_description',
|
||||
'tone_guidelines', // JSON
|
||||
'messaging_guidelines', // JSON
|
||||
'logo_usage_rules',
|
||||
'color_usage_rules',
|
||||
'dos_and_donts',
|
||||
|
||||
// Metadata
|
||||
'is_active',
|
||||
'is_default',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'colors' => 'array',
|
||||
'fonts' => 'array',
|
||||
'tone_guidelines' => 'array',
|
||||
'messaging_guidelines' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
'is_default' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
// Relationships
|
||||
|
||||
/**
|
||||
* Polymorphic owner (Business or Brand)
|
||||
*/
|
||||
public function owner(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
// Helper Methods
|
||||
|
||||
/**
|
||||
* Get primary color
|
||||
*/
|
||||
public function getPrimaryColor(): string
|
||||
{
|
||||
$colors = $this->colors ?? [];
|
||||
|
||||
return $colors['primary'] ?? '#000000';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all brand colors
|
||||
*/
|
||||
public function getAllColors(): array
|
||||
{
|
||||
return $this->colors ?? [
|
||||
'primary' => '#000000',
|
||||
'secondary' => '#666666',
|
||||
'accent' => '#0066cc',
|
||||
'text' => '#333333',
|
||||
'background' => '#ffffff',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get logo URL (primary by default)
|
||||
*/
|
||||
public function getLogoUrl(?string $variant = 'primary'): ?string
|
||||
{
|
||||
$column = "logo_{$variant}_path";
|
||||
|
||||
if (! $this->$column) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return asset('storage/'.$this->$column);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if kit has a logo variant
|
||||
*/
|
||||
public function hasLogo(string $variant = 'primary'): bool
|
||||
{
|
||||
$column = "logo_{$variant}_path";
|
||||
|
||||
return ! empty($this->$column) && \Storage::disk('public')->exists($this->$column);
|
||||
}
|
||||
}
|
||||
151
app/Models/BrandMedia.php
Normal file
151
app/Models/BrandMedia.php
Normal file
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class BrandMedia extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'brand_id',
|
||||
'uploaded_by_user_id',
|
||||
|
||||
// File Information
|
||||
'file_path',
|
||||
'file_name',
|
||||
'file_type',
|
||||
'file_extension',
|
||||
'file_size',
|
||||
'width',
|
||||
'height',
|
||||
|
||||
// Categorization
|
||||
'media_type', // logo, product_image, banner, social_media, document
|
||||
'category',
|
||||
'tags', // JSON
|
||||
|
||||
// Metadata
|
||||
'title',
|
||||
'description',
|
||||
'alt_text',
|
||||
'metadata', // JSON
|
||||
|
||||
// Usage
|
||||
'is_public',
|
||||
'is_approved',
|
||||
'approved_at',
|
||||
'approved_by_user_id',
|
||||
|
||||
// Organization
|
||||
'sort_order',
|
||||
'is_featured',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'tags' => 'array',
|
||||
'metadata' => 'array',
|
||||
'is_public' => 'boolean',
|
||||
'is_approved' => 'boolean',
|
||||
'approved_at' => 'datetime',
|
||||
'sort_order' => 'integer',
|
||||
'is_featured' => 'boolean',
|
||||
'file_size' => 'integer',
|
||||
'width' => 'integer',
|
||||
'height' => 'integer',
|
||||
];
|
||||
|
||||
// Relationships
|
||||
|
||||
public function brand(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Brand::class);
|
||||
}
|
||||
|
||||
public function uploadedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'uploaded_by_user_id');
|
||||
}
|
||||
|
||||
public function approvedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'approved_by_user_id');
|
||||
}
|
||||
|
||||
// Scopes
|
||||
|
||||
public function scopeApproved($query)
|
||||
{
|
||||
return $query->where('is_approved', true);
|
||||
}
|
||||
|
||||
public function scopeByType($query, string $type)
|
||||
{
|
||||
return $query->where('media_type', $type);
|
||||
}
|
||||
|
||||
public function scopeByCategory($query, string $category)
|
||||
{
|
||||
return $query->where('category', $category);
|
||||
}
|
||||
|
||||
public function scopePublic($query)
|
||||
{
|
||||
return $query->where('is_public', true);
|
||||
}
|
||||
|
||||
// Helper Methods
|
||||
|
||||
/**
|
||||
* Get public URL for the media file
|
||||
*/
|
||||
public function getUrl(): string
|
||||
{
|
||||
return asset('storage/'.$this->file_path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file size in human-readable format
|
||||
*/
|
||||
public function getFileSizeHuman(): string
|
||||
{
|
||||
$bytes = $this->file_size;
|
||||
$units = ['B', 'KB', 'MB', 'GB'];
|
||||
$i = 0;
|
||||
|
||||
while ($bytes >= 1024 && $i < 3) {
|
||||
$bytes /= 1024;
|
||||
$i++;
|
||||
}
|
||||
|
||||
return round($bytes, 2).' '.$units[$i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file is an image
|
||||
*/
|
||||
public function isImage(): bool
|
||||
{
|
||||
return in_array($this->file_extension, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file is a video
|
||||
*/
|
||||
public function isVideo(): bool
|
||||
{
|
||||
return in_array($this->file_extension, ['mp4', 'mov', 'avi', 'webm']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file is a document
|
||||
*/
|
||||
public function isDocument(): bool
|
||||
{
|
||||
return in_array($this->file_extension, ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx']);
|
||||
}
|
||||
}
|
||||
171
app/Models/Broadcast.php
Normal file
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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -172,6 +172,39 @@ class Business extends Model implements AuditableContract
|
||||
'approved_at',
|
||||
'approved_by',
|
||||
'notes',
|
||||
|
||||
// Order Settings
|
||||
'separate_orders_by_brand',
|
||||
'auto_increment_order_ids',
|
||||
'show_mark_as_paid',
|
||||
'display_crm_license_on_orders',
|
||||
'order_minimum',
|
||||
'default_shipping_charge',
|
||||
'free_shipping_minimum',
|
||||
'order_disclaimer',
|
||||
'order_invoice_footer',
|
||||
'prevent_order_editing',
|
||||
'az_require_patient_count',
|
||||
'az_require_allotment_verification',
|
||||
|
||||
// Invoice Settings
|
||||
'invoice_payable_company_name',
|
||||
'invoice_payable_address',
|
||||
'invoice_payable_city',
|
||||
'invoice_payable_state',
|
||||
'invoice_payable_zipcode',
|
||||
|
||||
// Notification Settings
|
||||
'new_order_email_notifications',
|
||||
'new_order_only_when_no_sales_rep',
|
||||
'new_order_do_not_send_to_admins',
|
||||
'order_accepted_email_notifications',
|
||||
'enable_shipped_emails_for_sales_reps',
|
||||
'platform_inquiry_email_notifications',
|
||||
'enable_manual_order_email_notifications',
|
||||
'manual_order_emails_internal_only',
|
||||
'low_inventory_email_notifications',
|
||||
'certified_seller_status_email_notifications',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@@ -186,6 +219,22 @@ class Business extends Model implements AuditableContract
|
||||
'credit_limit' => 'decimal:2',
|
||||
'tax_rate' => 'decimal:2',
|
||||
'tax_exempt' => 'boolean',
|
||||
// Order Settings
|
||||
'separate_orders_by_brand' => 'boolean',
|
||||
'auto_increment_order_ids' => 'boolean',
|
||||
'show_mark_as_paid' => 'boolean',
|
||||
'display_crm_license_on_orders' => 'boolean',
|
||||
'order_minimum' => 'decimal:2',
|
||||
'default_shipping_charge' => 'decimal:2',
|
||||
'free_shipping_minimum' => 'decimal:2',
|
||||
'az_require_patient_count' => 'boolean',
|
||||
'az_require_allotment_verification' => 'boolean',
|
||||
// Notification Settings
|
||||
'new_order_only_when_no_sales_rep' => 'boolean',
|
||||
'new_order_do_not_send_to_admins' => 'boolean',
|
||||
'enable_shipped_emails_for_sales_reps' => 'boolean',
|
||||
'enable_manual_order_email_notifications' => 'boolean',
|
||||
'manual_order_emails_internal_only' => 'boolean',
|
||||
];
|
||||
|
||||
// LeafLink-aligned Relationships
|
||||
|
||||
@@ -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
|
||||
|
||||
104
app/Models/Conversion.php
Normal file
104
app/Models/Conversion.php
Normal file
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
|
||||
/**
|
||||
* Conversion Model
|
||||
*
|
||||
* Represents a manufacturing conversion process where input materials/batches
|
||||
* are transformed into output products/batches.
|
||||
*
|
||||
* Examples:
|
||||
* - hash_wash: Converting raw material into washed hash
|
||||
* - trim_to_extract: Converting trim into concentrate
|
||||
* - flower_to_preroll: Converting flower into pre-rolls
|
||||
*/
|
||||
class Conversion extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'conversion_type',
|
||||
'operator_id',
|
||||
'conversion_date',
|
||||
'notes',
|
||||
'status',
|
||||
'input_weight',
|
||||
'output_weight',
|
||||
'yield_percentage',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'conversion_date' => 'datetime',
|
||||
'metadata' => 'array',
|
||||
'input_weight' => 'decimal:2',
|
||||
'output_weight' => 'decimal:2',
|
||||
'yield_percentage' => 'decimal:2',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the business that owns the conversion
|
||||
*/
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the operator (user) who performed the conversion
|
||||
*/
|
||||
public function operator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'operator_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the input batches used in this conversion
|
||||
*/
|
||||
public function inputBatches(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Batch::class, 'conversion_inputs')
|
||||
->withPivot(['quantity', 'unit', 'notes'])
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the batch created from this conversion
|
||||
*/
|
||||
public function batchCreated(): HasOne
|
||||
{
|
||||
return $this->hasOne(Batch::class, 'conversion_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to filter by conversion type
|
||||
*/
|
||||
public function scopeOfType($query, string $type)
|
||||
{
|
||||
return $query->where('conversion_type', $type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to filter by business
|
||||
*/
|
||||
public function scopeForBusiness($query, int $businessId)
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Stage 1 data from metadata
|
||||
*/
|
||||
public function getStage1Data(): ?array
|
||||
{
|
||||
return $this->metadata['stage_1'] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -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([
|
||||
|
||||
231
app/Models/Marketing/Template.php
Normal file
231
app/Models/Marketing/Template.php
Normal file
@@ -0,0 +1,231 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Marketing;
|
||||
|
||||
use App\Models\Brand;
|
||||
use App\Models\Business;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Template extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'category_id',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'name',
|
||||
'description',
|
||||
'thumbnail',
|
||||
'design_json',
|
||||
'mjml_content',
|
||||
'html_content',
|
||||
'plain_text',
|
||||
'is_system_template',
|
||||
'is_public',
|
||||
'is_premium',
|
||||
'template_type',
|
||||
'tags',
|
||||
'usage_count',
|
||||
'usage_limit',
|
||||
'usage_period',
|
||||
'last_used_at',
|
||||
'version',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'design_json' => 'array',
|
||||
'tags' => 'array',
|
||||
'is_system_template' => 'boolean',
|
||||
'is_public' => 'boolean',
|
||||
'is_premium' => 'boolean',
|
||||
'last_used_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected $appends = ['is_editable'];
|
||||
|
||||
// Relationships
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(TemplateCategory::class, 'category_id');
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function updater(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'updated_by');
|
||||
}
|
||||
|
||||
public function versions(): HasMany
|
||||
{
|
||||
return $this->hasMany(TemplateVersion::class)->orderBy('version_number', 'desc');
|
||||
}
|
||||
|
||||
public function analytics(): HasMany
|
||||
{
|
||||
return $this->hasMany(TemplateAnalytics::class);
|
||||
}
|
||||
|
||||
public function brands(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Brand::class, 'brand_templates')
|
||||
->withPivot(['is_favorite', 'usage_count',
|
||||
'usage_limit',
|
||||
'usage_period', 'last_used_at', 'added_by'])
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function broadcasts(): HasMany
|
||||
{
|
||||
return $this->hasMany(Broadcast::class, 'template_id');
|
||||
}
|
||||
|
||||
// Scopes
|
||||
|
||||
public function scopeForBusiness($query, $businessId)
|
||||
{
|
||||
return $query->where(function ($q) use ($businessId) {
|
||||
$q->where('business_id', $businessId)
|
||||
->orWhere('is_system_template', true);
|
||||
});
|
||||
}
|
||||
|
||||
public function scopeSystemTemplates($query)
|
||||
{
|
||||
return $query->where('is_system_template', true);
|
||||
}
|
||||
|
||||
public function scopeUserTemplates($query, $businessId)
|
||||
{
|
||||
return $query->where('business_id', $businessId)
|
||||
->where('is_system_template', false);
|
||||
}
|
||||
|
||||
public function scopeByType($query, string $type)
|
||||
{
|
||||
return $query->where('template_type', $type);
|
||||
}
|
||||
|
||||
public function scopeByCategory($query, $categoryId)
|
||||
{
|
||||
return $query->where('category_id', $categoryId);
|
||||
}
|
||||
|
||||
public function scopeRecent($query)
|
||||
{
|
||||
return $query->orderBy('created_at', 'desc');
|
||||
}
|
||||
|
||||
public function scopePopular($query)
|
||||
{
|
||||
return $query->orderBy('usage_count',
|
||||
'usage_limit',
|
||||
'usage_period', 'desc');
|
||||
}
|
||||
|
||||
public function scopeWithTags($query, array $tags)
|
||||
{
|
||||
return $query->where(function ($q) use ($tags) {
|
||||
foreach ($tags as $tag) {
|
||||
$q->orWhereJsonContains('tags', $tag);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Helper Methods
|
||||
|
||||
public function getIsEditableAttribute(): bool
|
||||
{
|
||||
// System templates are read-only
|
||||
if ($this->is_system_template) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// User can edit their own business templates
|
||||
return $this->business_id === currentBusiness()?->id;
|
||||
}
|
||||
|
||||
public function canBeDeleted(): bool
|
||||
{
|
||||
// System templates cannot be deleted
|
||||
if ($this->is_system_template) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if template is in use
|
||||
if ($this->usage_count > 0 || $this->broadcasts()->exists()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function incrementUsage(): void
|
||||
{
|
||||
$this->increment('usage_count');
|
||||
$this->update(['last_used_at' => now()]);
|
||||
}
|
||||
|
||||
public function createVersion(?string $notes = null): TemplateVersion
|
||||
{
|
||||
return $this->versions()->create([
|
||||
'version_number' => $this->version,
|
||||
'version_name' => "Version {$this->version}",
|
||||
'change_notes' => $notes,
|
||||
'design_json' => $this->design_json,
|
||||
'mjml_content' => $this->mjml_content,
|
||||
'html_content' => $this->html_content,
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function restoreVersion(TemplateVersion $version): void
|
||||
{
|
||||
$this->update([
|
||||
'design_json' => $version->design_json,
|
||||
'mjml_content' => $version->mjml_content,
|
||||
'html_content' => $version->html_content,
|
||||
'version' => $this->version + 1,
|
||||
]);
|
||||
|
||||
$this->createVersion("Restored from Version {$version->version_number}");
|
||||
}
|
||||
|
||||
public function duplicate(string $newName): self
|
||||
{
|
||||
$duplicate = $this->replicate();
|
||||
$duplicate->name = $newName;
|
||||
$duplicate->is_system_template = false;
|
||||
$duplicate->usage_count = 0;
|
||||
$duplicate->last_used_at = null;
|
||||
$duplicate->created_by = auth()->id();
|
||||
$duplicate->save();
|
||||
|
||||
return $duplicate;
|
||||
}
|
||||
|
||||
public function getAnalyticsForBusiness($businessId, $brandId = null): ?TemplateAnalytics
|
||||
{
|
||||
return $this->analytics()
|
||||
->where('business_id', $businessId)
|
||||
->where('brand_id', $brandId)
|
||||
->first();
|
||||
}
|
||||
}
|
||||
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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
55
app/Models/Marketing/TemplateBlock.php
Normal file
55
app/Models/Marketing/TemplateBlock.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Marketing;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class TemplateBlock extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'created_by',
|
||||
'name',
|
||||
'description',
|
||||
'thumbnail',
|
||||
'block_type',
|
||||
'design_json',
|
||||
'usage_count',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'design_json' => 'array',
|
||||
];
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function scopeForBusiness($query, $businessId)
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
public function scopeByType($query, string $type)
|
||||
{
|
||||
return $query->where('block_type', $type);
|
||||
}
|
||||
|
||||
public function incrementUsage(): void
|
||||
{
|
||||
$this->increment('usage_count');
|
||||
}
|
||||
}
|
||||
36
app/Models/Marketing/TemplateCategory.php
Normal file
36
app/Models/Marketing/TemplateCategory.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Marketing;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class TemplateCategory extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'slug',
|
||||
'description',
|
||||
'icon',
|
||||
'color',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
public function templates(): HasMany
|
||||
{
|
||||
return $this->hasMany(Template::class, 'category_id');
|
||||
}
|
||||
|
||||
public function scopeSorted($query)
|
||||
{
|
||||
return $query->orderBy('sort_order');
|
||||
}
|
||||
|
||||
public function getTemplateCountAttribute(): int
|
||||
{
|
||||
return $this->templates()->count();
|
||||
}
|
||||
}
|
||||
38
app/Models/Marketing/TemplateVersion.php
Normal file
38
app/Models/Marketing/TemplateVersion.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Marketing;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class TemplateVersion extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'template_id',
|
||||
'created_by',
|
||||
'version_number',
|
||||
'version_name',
|
||||
'change_notes',
|
||||
'design_json',
|
||||
'mjml_content',
|
||||
'html_content',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'design_json' => 'array',
|
||||
];
|
||||
|
||||
public function template(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Template::class);
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
}
|
||||
48
app/Models/PaymentMethod.php
Normal file
48
app/Models/PaymentMethod.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class PaymentMethod extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'card_brand',
|
||||
'last4',
|
||||
'exp_month',
|
||||
'exp_year',
|
||||
'is_active',
|
||||
'billing_name',
|
||||
'billing_address_line1',
|
||||
'billing_address_line2',
|
||||
'billing_city',
|
||||
'billing_state',
|
||||
'billing_postal_code',
|
||||
'billing_country',
|
||||
'stripe_payment_method_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
'exp_month' => 'integer',
|
||||
'exp_year' => 'integer',
|
||||
];
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function isExpired(): bool
|
||||
{
|
||||
$now = now();
|
||||
|
||||
return $now->year > $this->exp_year ||
|
||||
($now->year === $this->exp_year && $now->month > $this->exp_month);
|
||||
}
|
||||
}
|
||||
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,155 +15,70 @@ use OwenIt\Auditing\Contracts\Auditable;
|
||||
|
||||
class Product extends Model implements Auditable
|
||||
{
|
||||
use BelongsToBusinessViaBrand, HasFactory, \OwenIt\Auditing\Auditable, SoftDeletes;
|
||||
use BelongsToBusinessViaBrand, HasFactory, HasHashid, \OwenIt\Auditing\Auditable, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
// Foreign Keys
|
||||
'hashid',
|
||||
'brand_id',
|
||||
'strain_id',
|
||||
'parent_product_id',
|
||||
'packaging_id',
|
||||
'unit_id',
|
||||
|
||||
// Product Identity
|
||||
'category_id',
|
||||
'name',
|
||||
'slug',
|
||||
'sku',
|
||||
'barcode',
|
||||
'description',
|
||||
'long_description',
|
||||
|
||||
// Product Type & Classification
|
||||
'type',
|
||||
'category',
|
||||
'product_line',
|
||||
'product_link',
|
||||
'creatives',
|
||||
|
||||
// BOM Flags
|
||||
'is_assembly',
|
||||
'is_raw_material',
|
||||
|
||||
// Configuration Flags
|
||||
'has_varieties',
|
||||
'sell_multiples',
|
||||
'fractional_quantities',
|
||||
'allow_sample',
|
||||
'is_fpr',
|
||||
'is_sellable',
|
||||
|
||||
// Pricing
|
||||
'wholesale_price',
|
||||
'msrp',
|
||||
'msrp_price',
|
||||
'cost_per_unit',
|
||||
'price_unit',
|
||||
|
||||
// Packaging & Units
|
||||
'net_weight',
|
||||
'weight_unit',
|
||||
'units_per_case',
|
||||
'is_case',
|
||||
'cased_qty',
|
||||
'is_box',
|
||||
'boxed_qty',
|
||||
|
||||
// Cannabis-specific
|
||||
'thc_percentage',
|
||||
'cbd_percentage',
|
||||
'thc_content_mg',
|
||||
'cbd_content_mg',
|
||||
'strain_value',
|
||||
'ingredients',
|
||||
'effects',
|
||||
'dosage_guidelines',
|
||||
|
||||
// Inventory & Status
|
||||
'quantity_on_hand',
|
||||
'quantity_allocated',
|
||||
'reorder_point',
|
||||
'min_order_qty',
|
||||
'max_order_qty',
|
||||
'low_stock_threshold',
|
||||
'low_stock_alert_enabled',
|
||||
'sync_bamboo',
|
||||
'is_active',
|
||||
'is_featured',
|
||||
'show_inventory_to_buyers',
|
||||
'status',
|
||||
|
||||
// Compliance & Tracking
|
||||
'metrc_id',
|
||||
'license_number',
|
||||
'arz_total_weight',
|
||||
'arz_usable_mmj',
|
||||
'harvest_date',
|
||||
'package_date',
|
||||
'test_date',
|
||||
'launch_date',
|
||||
|
||||
// Display & SEO
|
||||
'sort_order',
|
||||
'brand_display_order',
|
||||
'image_path',
|
||||
'meta_title',
|
||||
'meta_description',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
// Pricing
|
||||
'wholesale_price' => 'decimal:2',
|
||||
'msrp' => 'decimal:2',
|
||||
'msrp_price' => 'decimal:2',
|
||||
'cost_per_unit' => 'decimal:2',
|
||||
|
||||
// Measurements
|
||||
'net_weight' => 'decimal:3',
|
||||
'strain_value' => 'decimal:2',
|
||||
'arz_total_weight' => 'decimal:3',
|
||||
'arz_usable_mmj' => 'decimal:3',
|
||||
|
||||
// Cannabis
|
||||
'thc_percentage' => 'decimal:2',
|
||||
'cbd_percentage' => 'decimal:2',
|
||||
'thc_content_mg' => 'decimal:2',
|
||||
'cbd_content_mg' => 'decimal:2',
|
||||
|
||||
// Inventory
|
||||
'quantity_on_hand' => 'integer',
|
||||
'quantity_allocated' => 'integer',
|
||||
'reorder_point' => 'integer',
|
||||
'min_order_qty' => 'integer',
|
||||
'max_order_qty' => 'integer',
|
||||
'low_stock_threshold' => 'integer',
|
||||
|
||||
// Packaging
|
||||
'units_per_case' => 'integer',
|
||||
'cased_qty' => 'integer',
|
||||
'boxed_qty' => 'integer',
|
||||
'brand_display_order' => 'integer',
|
||||
'sort_order' => 'integer',
|
||||
|
||||
// Booleans
|
||||
'is_assembly' => 'boolean',
|
||||
'is_raw_material' => 'boolean',
|
||||
'has_varieties' => 'boolean',
|
||||
'sell_multiples' => 'boolean',
|
||||
'fractional_quantities' => 'boolean',
|
||||
'allow_sample' => 'boolean',
|
||||
'is_fpr' => 'boolean',
|
||||
'is_sellable' => 'boolean',
|
||||
'is_case' => 'boolean',
|
||||
'is_box' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
'is_featured' => 'boolean',
|
||||
'show_inventory_to_buyers' => 'boolean',
|
||||
'low_stock_alert_enabled' => 'boolean',
|
||||
'sync_bamboo' => 'boolean',
|
||||
|
||||
// Dates
|
||||
'sort_order' => 'integer',
|
||||
'harvest_date' => 'date',
|
||||
'package_date' => 'date',
|
||||
'test_date' => 'date',
|
||||
'launch_date' => 'date',
|
||||
];
|
||||
|
||||
// Audit configuration - exclude timestamps and system-managed fields
|
||||
@@ -194,24 +110,14 @@ class Product extends Model implements Auditable
|
||||
return $this->belongsTo(Brand::class);
|
||||
}
|
||||
|
||||
public function productLine(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProductLine::class);
|
||||
}
|
||||
|
||||
public function strain(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Strain::class);
|
||||
}
|
||||
|
||||
public function packaging(): BelongsTo
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProductPackaging::class, 'packaging_id');
|
||||
}
|
||||
|
||||
public function unit(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Unit::class);
|
||||
return $this->belongsTo(ProductCategory::class, 'category_id');
|
||||
}
|
||||
|
||||
public function parent(): BelongsTo
|
||||
@@ -226,7 +132,7 @@ class Product extends Model implements Auditable
|
||||
|
||||
public function images(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProductImage::class)->orderBy('sort_order');
|
||||
return $this->hasMany(ProductImage::class)->orderBy('order');
|
||||
}
|
||||
|
||||
public function primaryImage(): HasMany
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
58
app/Models/Subscription.php
Normal file
58
app/Models/Subscription.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Subscription extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'plan_id',
|
||||
'plan_name',
|
||||
'plan_price',
|
||||
'status',
|
||||
'current_period_start',
|
||||
'current_period_end',
|
||||
'trial_ends_at',
|
||||
'scheduled_plan_id',
|
||||
'scheduled_plan_name',
|
||||
'scheduled_plan_price',
|
||||
'scheduled_change_date',
|
||||
'default_payment_method_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'plan_price' => 'decimal:2',
|
||||
'scheduled_plan_price' => 'decimal:2',
|
||||
'current_period_start' => 'date',
|
||||
'current_period_end' => 'date',
|
||||
'trial_ends_at' => 'date',
|
||||
'scheduled_change_date' => 'date',
|
||||
];
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function defaultPaymentMethod(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PaymentMethod::class, 'default_payment_method_id');
|
||||
}
|
||||
|
||||
public function invoices(): HasMany
|
||||
{
|
||||
return $this->hasMany(SubscriptionInvoice::class);
|
||||
}
|
||||
|
||||
public function hasScheduledDowngrade(): bool
|
||||
{
|
||||
return $this->scheduled_plan_id !== null && $this->scheduled_change_date !== null;
|
||||
}
|
||||
}
|
||||
61
app/Models/SubscriptionInvoice.php
Normal file
61
app/Models/SubscriptionInvoice.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class SubscriptionInvoice extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'subscription_id',
|
||||
'business_id',
|
||||
'invoice_number',
|
||||
'type',
|
||||
'amount',
|
||||
'status',
|
||||
'invoice_date',
|
||||
'due_date',
|
||||
'paid_at',
|
||||
'line_items',
|
||||
'payment_method_id',
|
||||
'stripe_invoice_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'amount' => 'decimal:2',
|
||||
'invoice_date' => 'date',
|
||||
'due_date' => 'date',
|
||||
'paid_at' => 'datetime',
|
||||
'line_items' => 'array',
|
||||
];
|
||||
|
||||
public function subscription(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Subscription::class);
|
||||
}
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function paymentMethod(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PaymentMethod::class);
|
||||
}
|
||||
|
||||
public function isPaid(): bool
|
||||
{
|
||||
return $this->status === 'paid';
|
||||
}
|
||||
|
||||
public function isPastDue(): bool
|
||||
{
|
||||
return $this->status === 'past_due' ||
|
||||
($this->status === 'pending' && $this->due_date->isPast());
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,50 @@ class User extends Authenticatable implements FilamentUser
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, HasRoles, Impersonate, Notifiable;
|
||||
|
||||
/**
|
||||
* Boot the model and automatically generate short UUIDs
|
||||
*/
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function ($model) {
|
||||
if (empty($model->uuid)) {
|
||||
$model->uuid = self::generateShortUuid();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique 5-character UUID without confusing characters
|
||||
* Excludes: 0, O, 1, l (zero, capital O, one, lowercase L)
|
||||
*/
|
||||
protected static function generateShortUuid(): string
|
||||
{
|
||||
// Character set excluding 0, O, 1, l for clarity
|
||||
$chars = '23456789ABCDEFGHJKMNPQRSTUVWXYZ';
|
||||
$maxAttempts = 100;
|
||||
$attempts = 0;
|
||||
|
||||
do {
|
||||
// Generate 5-character random string
|
||||
$uuid = '';
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$uuid .= $chars[random_int(0, strlen($chars) - 1)];
|
||||
}
|
||||
|
||||
// Check if unique in database
|
||||
$exists = self::where('uuid', $uuid)->exists();
|
||||
$attempts++;
|
||||
|
||||
if ($attempts >= $maxAttempts) {
|
||||
throw new \RuntimeException('Unable to generate unique short UUID after '.$maxAttempts.' attempts');
|
||||
}
|
||||
} while ($exists);
|
||||
|
||||
return $uuid;
|
||||
}
|
||||
|
||||
/**
|
||||
* User type constants
|
||||
*/
|
||||
@@ -53,12 +97,14 @@ class User extends Authenticatable implements FilamentUser
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'email',
|
||||
'password',
|
||||
'phone',
|
||||
'position', // Job title/role
|
||||
'company', // Company affiliation
|
||||
'user_type', // admin, buyer, seller
|
||||
'status', // active, inactive, suspended
|
||||
'business_onboarding_completed',
|
||||
@@ -70,6 +116,18 @@ class User extends Authenticatable implements FilamentUser
|
||||
'preferred_contact_method', // email, phone, sms
|
||||
'timezone',
|
||||
'language_preference',
|
||||
|
||||
// Profile
|
||||
'avatar_path',
|
||||
'use_gravatar',
|
||||
|
||||
// Social Media
|
||||
'linkedin_url',
|
||||
'twitter_url',
|
||||
'facebook_url',
|
||||
'instagram_url',
|
||||
'tiktok_url',
|
||||
'github_url',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -96,6 +154,14 @@ class User extends Authenticatable implements FilamentUser
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the route key for the model (use UUID instead of ID)
|
||||
*/
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
return 'uuid';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's full name by combining first and last name
|
||||
* This accessor ensures Filament can display the user's name
|
||||
@@ -184,6 +250,16 @@ class User extends Authenticatable implements FilamentUser
|
||||
return $this->hasMany(Contact::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Brands this user has access to (brand team membership)
|
||||
*/
|
||||
public function brands(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Brand::class, 'brand_user')
|
||||
->withPivot(['role', 'is_primary', 'permissions', 'role_template', 'permissions_updated_at'])
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
// Helper methods for business associations
|
||||
public function isPrimaryContactFor(Business $business): bool
|
||||
{
|
||||
|
||||
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}";
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user