Compare commits
42 Commits
fix/gitea-
...
feature/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f074edc9c4 | ||
|
|
057bf83b74 | ||
|
|
f6352637dd | ||
|
|
6655b19275 | ||
|
|
2343c2176c | ||
|
|
f60420d551 | ||
|
|
64d1a0dad2 | ||
|
|
32e5e249fb | ||
|
|
c7b7a23fce | ||
|
|
3e986171a1 | ||
|
|
d2eb6e11ea | ||
|
|
0c40799ddc | ||
|
|
5215d4a077 | ||
|
|
75ec53da63 | ||
|
|
4cf62d92e4 | ||
|
|
59d81b8f42 | ||
|
|
7c0ec86823 | ||
|
|
2c3d12a22c | ||
|
|
d5ea59e83e | ||
|
|
1371d2a59c | ||
|
|
4dd2e3ae64 | ||
|
|
a133477f9f | ||
|
|
1649909b73 | ||
|
|
66d55c4f0a | ||
|
|
5dd60cc71e | ||
|
|
a5ac7d4217 | ||
|
|
eb05a6bcf0 | ||
|
|
572c207e39 | ||
|
|
ef5f430e90 | ||
|
|
a99a0807d0 | ||
|
|
a0194bad9b | ||
|
|
91451893fe | ||
|
|
1786c2edb1 | ||
|
|
9a81a22cc5 | ||
|
|
19bfa889b7 | ||
|
|
c4bd508241 | ||
|
|
b404a533b3 | ||
|
|
65380b9649 | ||
|
|
52facb768e | ||
|
|
e9230495b4 | ||
|
|
06869cf05d | ||
|
|
555b988c4f |
22
.env.example
22
.env.example
@@ -34,6 +34,7 @@ SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
|
||||
BROADCAST_CONNECTION=reverb
|
||||
# FILESYSTEM_DISK options: local (development), public (local public), minio (production)
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=redis
|
||||
|
||||
@@ -77,25 +78,18 @@ MAIL_ENCRYPTION=null
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
# AWS/MinIO S3 Storage Configuration
|
||||
# Local development: Use FILESYSTEM_DISK=public (default)
|
||||
# Production: Use FILESYSTEM_DISK=s3 with MinIO credentials below
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_BUCKET=
|
||||
AWS_ENDPOINT=
|
||||
AWS_URL=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
# Production MinIO Configuration (example):
|
||||
# FILESYSTEM_DISK=s3
|
||||
# AWS_ACCESS_KEY_ID=TrLoFnMOVQC2CqLm9711
|
||||
# AWS_SECRET_ACCESS_KEY=4tfik06LitWz70L4VLIA45yXla4gi3zQI2IA3oSZ
|
||||
# AWS_DEFAULT_REGION=us-east-1
|
||||
# AWS_BUCKET=media
|
||||
# AWS_ENDPOINT=https://cdn.cannabrands.app
|
||||
# AWS_URL=https://cdn.cannabrands.app/media
|
||||
# AWS_USE_PATH_STYLE_ENDPOINT=true
|
||||
# MinIO Configuration (Production Object Storage)
|
||||
# Set FILESYSTEM_DISK=minio in production
|
||||
MINIO_ACCESS_KEY=
|
||||
MINIO_SECRET_KEY=
|
||||
MINIO_REGION=us-east-1
|
||||
MINIO_BUCKET=cannabrands
|
||||
MINIO_ENDPOINT=
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -58,4 +58,12 @@ core.*
|
||||
!resources/**/*.png
|
||||
!resources/**/*.jpg
|
||||
!resources/**/*.jpeg
|
||||
|
||||
# Nexus HTML build artifacts and generated files
|
||||
nexus-html@*/bootstrap/cache/
|
||||
nexus-html@*/storage/
|
||||
nexus-html@*/public/build/
|
||||
nexus-html@*/database/*.sqlite*
|
||||
|
||||
.claude/settings.local.json
|
||||
CLAUDE.local.md
|
||||
|
||||
403
BATCH_AND_LAB_SYSTEM.md
Normal file
403
BATCH_AND_LAB_SYSTEM.md
Normal file
@@ -0,0 +1,403 @@
|
||||
# Batch & Lab (COA) System - Definitive Guide
|
||||
|
||||
**Last Updated:** 2025-01-08
|
||||
**Status:** Architecture Definition - DO NOT DEVIATE FROM THIS
|
||||
|
||||
---
|
||||
|
||||
## Core Terminology (NEVER USE OTHER TERMS)
|
||||
|
||||
| Term | Definition | Example |
|
||||
|------|------------|---------|
|
||||
| **Component** | Input material used to make products | Flower, rosin, trim, tubes, jars, labels |
|
||||
| **Batch** | A tested bucket of finite material with a COA | 500 lbs of tested flower, 500 tested prerolls |
|
||||
| **Lab / COA** | Certificate of Analysis - the test result | Lab report showing THC%, CBD%, contaminants |
|
||||
| **Product** | Parent concept with variants | "Alien Market Preroll" |
|
||||
| **SKU / Variant** | Sellable item (size/package variation) | "Alien Market Preroll - 1pk", "- 5pk" |
|
||||
| **BOM** | Bill of Materials - recipe for a SKU | 3.5g flower + jar + label = 1/8oz SKU |
|
||||
| **Homogenized Product** | Mixed components requiring NEW test | Flower + rosin preroll (must be tested) |
|
||||
| **Conversion** | Refining raw material | Fresh frozen → rosin, flower → preroll |
|
||||
|
||||
---
|
||||
|
||||
## The Two Types of Batches
|
||||
|
||||
### Type 1: Component Batch (Unconverted or Converted Material)
|
||||
|
||||
**Definition:** A tested quantity of input material
|
||||
|
||||
**Examples:**
|
||||
- 500 lbs cured flower (tested, has COA)
|
||||
- 50 lbs rosin extracted from fresh frozen (tested, has COA)
|
||||
- 100 lbs trim (tested, has COA)
|
||||
|
||||
**Rules:**
|
||||
- Each batch has ONE Lab/COA
|
||||
- Finite quantity tracked as inventory depletes
|
||||
- When depleted, cannot reuse COA - must create NEW batch with NEW test
|
||||
- Multiple SKUs can pull from same batch (all share same COA)
|
||||
|
||||
**Usage:**
|
||||
```
|
||||
Component Batch #123: 500 lbs "Alien Market" flower (tested 3/15/24)
|
||||
├── SKU 1: Alien Market Flower - 1/8oz jar (sells 142 units = 17.75 lbs)
|
||||
├── SKU 2: Alien Market Flower - 1/4oz jar (sells 200 units = 50 lbs)
|
||||
├── SKU 3: Alien Market Flower - 1oz jar (sells 100 units = 100 lbs)
|
||||
└── Remaining: 332.25 lbs available
|
||||
|
||||
All 3 SKUs show SAME COA (Component Batch #123's lab result)
|
||||
```
|
||||
|
||||
### Type 2: Homogenized Product Batch (Mixed Components)
|
||||
|
||||
**Definition:** When you MIX multiple component batches, creating a NEW product that requires NEW testing
|
||||
|
||||
**Example:**
|
||||
```
|
||||
Component Batch #123: Flower (has COA #1)
|
||||
Component Batch #456: Rosin (has COA #2)
|
||||
↓
|
||||
Mix together to make enhanced prerolls
|
||||
↓
|
||||
Homogenized Product Batch #789: 500 enhanced prerolls (requires NEW COA #3)
|
||||
```
|
||||
|
||||
**Critical Rule:**
|
||||
- Homogenized product MUST be tested (new Lab/COA required)
|
||||
- QR code shows MULTIPLE COAs:
|
||||
- **Primary:** Homogenized product COA (the preroll test)
|
||||
- **Source:** Component COAs (flower COA, rosin COA) for traceability
|
||||
|
||||
**Exception:** Rosin does NOT need testing if used internally in your own products. It only needs testing if SOLD AS A COMPONENT to another buyer.
|
||||
|
||||
---
|
||||
|
||||
## Product vs SKU (Parent vs Variant)
|
||||
|
||||
### Current Schema Reality
|
||||
|
||||
The `products` table serves DUAL purposes:
|
||||
|
||||
**Parent Products:**
|
||||
- `has_varieties = true`
|
||||
- `parent_product_id = null`
|
||||
- Example: "Alien Market Preroll"
|
||||
|
||||
**Variant Products (SKUs):**
|
||||
- `has_varieties = false`
|
||||
- `parent_product_id = <parent_id>`
|
||||
- Example: "Alien Market Preroll - 1pk"
|
||||
|
||||
### How It Works
|
||||
|
||||
```
|
||||
Product (Parent)
|
||||
├── id: 100
|
||||
├── name: "Alien Market Preroll"
|
||||
├── has_varieties: true
|
||||
└── description, images (shared)
|
||||
|
||||
Product (Variant 1 - SKU)
|
||||
├── id: 101
|
||||
├── name: "Alien Market Preroll - 1pk"
|
||||
├── parent_product_id: 100
|
||||
├── sku: "AM-PR-1PK"
|
||||
├── wholesale_price: $5.00
|
||||
└── bom_id: 50 (recipe)
|
||||
|
||||
Product (Variant 2 - SKU)
|
||||
├── id: 102
|
||||
├── name: "Alien Market Preroll - 5pk"
|
||||
├── parent_product_id: 100
|
||||
├── sku: "AM-PR-5PK"
|
||||
├── wholesale_price: $22.00
|
||||
└── bom_id: 51 (recipe)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bill of Materials (BOM)
|
||||
|
||||
**Rule:** EVERYTHING goes through a BOM to become a SKU, even "simple" products
|
||||
|
||||
### Simple Product BOM
|
||||
|
||||
```
|
||||
SKU: "Alien Market Flower - 1/8oz"
|
||||
BOM Components:
|
||||
├── 3.5g flower (from Component Batch #123)
|
||||
├── 1x jar
|
||||
└── 1x label
|
||||
```
|
||||
|
||||
### Complex Product BOM
|
||||
|
||||
```
|
||||
SKU: "Alien Market Preroll - 1pk"
|
||||
BOM Components:
|
||||
├── 1x preroll (intermediate product)
|
||||
│ └── Made from Component Batch #123 (flower)
|
||||
├── 1x tube
|
||||
└── 1x label
|
||||
```
|
||||
|
||||
### Homogenized Product BOM
|
||||
|
||||
```
|
||||
SKU: "Enhanced Preroll - 1pk"
|
||||
BOM Components:
|
||||
├── 1x enhanced preroll (Homogenized Product Batch #789)
|
||||
│ ├── Flower (Component Batch #123 - COA #1)
|
||||
│ └── Rosin (Component Batch #456 - COA #2)
|
||||
│ └── NEW TEST REQUIRED (COA #3)
|
||||
├── 1x tube
|
||||
└── 1x label
|
||||
|
||||
QR Code shows: COA #3 (primary) + COA #1 + COA #2 (sources)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Batch Linking Rules
|
||||
|
||||
### Rule 1: Unconverted Component → SKU
|
||||
|
||||
**When:** Selling flower, trim, or concentrate as-is (just packaging)
|
||||
|
||||
**Batch Behavior:** Reuse existing Component Batch ID
|
||||
|
||||
```
|
||||
Component Batch #123: 500 lbs flower (tested)
|
||||
↓
|
||||
SKU 1: 1/8oz jar → uses Batch #123 → shows COA #123
|
||||
SKU 2: 1/4oz jar → uses Batch #123 → shows COA #123
|
||||
SKU 3: 1oz jar → uses Batch #123 → shows COA #123
|
||||
|
||||
All SKUs share the SAME batch and SAME COA
|
||||
```
|
||||
|
||||
### Rule 2: Homogenized Product → SKU
|
||||
|
||||
**When:** Mixing multiple components (conversion happened)
|
||||
|
||||
**Batch Behavior:** Create NEW Homogenized Product Batch ID with NEW COA
|
||||
|
||||
```
|
||||
Component Batch #123: Flower (COA #1)
|
||||
Component Batch #456: Rosin (COA #2)
|
||||
↓
|
||||
Manufacturing: Mix flower + rosin
|
||||
↓
|
||||
Send sample to lab → get NEW COA #3
|
||||
↓
|
||||
Create Homogenized Product Batch #789 (COA #3)
|
||||
↓
|
||||
SKU 1: Enhanced Preroll 1pk → uses Batch #789 → shows COA #3, #1, #2
|
||||
SKU 2: Enhanced Preroll 5pk → uses Batch #789 → shows COA #3, #1, #2
|
||||
|
||||
All SKUs share the SAME homogenized batch but show MULTIPLE COAs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Correct Database Schema
|
||||
|
||||
### batches
|
||||
|
||||
```sql
|
||||
CREATE TABLE batches (
|
||||
id BIGINT PRIMARY KEY,
|
||||
business_id BIGINT NOT NULL, -- Multi-tenancy
|
||||
|
||||
-- What type of batch?
|
||||
batch_type ENUM('component', 'homogenized') NOT NULL,
|
||||
|
||||
-- If component batch
|
||||
component_id BIGINT NULL, -- Links to components table
|
||||
|
||||
-- If homogenized batch (links to source component batches)
|
||||
-- Uses batch_source_components pivot table
|
||||
|
||||
-- Identification
|
||||
batch_number VARCHAR(100) UNIQUE NOT NULL,
|
||||
|
||||
-- Inventory
|
||||
quantity_total INTEGER NOT NULL,
|
||||
quantity_remaining INTEGER NOT NULL,
|
||||
quantity_unit VARCHAR(20), -- lbs, g, units
|
||||
|
||||
-- Primary COA for this batch
|
||||
primary_coa_id BIGINT, -- Links to labs table
|
||||
|
||||
-- Dates
|
||||
production_date DATE,
|
||||
test_date DATE,
|
||||
expiration_date DATE,
|
||||
|
||||
-- Status
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
is_tested BOOLEAN DEFAULT false,
|
||||
is_quarantined BOOLEAN DEFAULT false,
|
||||
|
||||
timestamps,
|
||||
soft_deletes
|
||||
);
|
||||
```
|
||||
|
||||
### batch_source_components (for homogenized products)
|
||||
|
||||
```sql
|
||||
CREATE TABLE batch_source_components (
|
||||
id BIGINT PRIMARY KEY,
|
||||
homogenized_batch_id BIGINT NOT NULL, -- The mixed product batch
|
||||
source_batch_id BIGINT NOT NULL, -- Component batch used
|
||||
quantity_used DECIMAL(10,2),
|
||||
unit VARCHAR(20),
|
||||
timestamps
|
||||
);
|
||||
```
|
||||
|
||||
### product_batches (pivot - which SKUs use which batches)
|
||||
|
||||
```sql
|
||||
CREATE TABLE product_batches (
|
||||
id BIGINT PRIMARY KEY,
|
||||
product_id BIGINT NOT NULL, -- The SKU (variant product)
|
||||
batch_id BIGINT NOT NULL, -- The batch being used
|
||||
quantity_allocated INTEGER DEFAULT 0,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
timestamps
|
||||
);
|
||||
```
|
||||
|
||||
### labs (COAs)
|
||||
|
||||
```sql
|
||||
-- Existing labs table
|
||||
-- Add batch_id column to link COAs to specific batches
|
||||
ALTER TABLE labs ADD COLUMN batch_id BIGINT NULL;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## QR Code Display Logic
|
||||
|
||||
### Simple Product (Component Batch)
|
||||
|
||||
```
|
||||
Scan QR → Load Batch #123 → Show:
|
||||
- Product Name
|
||||
- Batch Number
|
||||
- Test Date
|
||||
- COA #123 (PDF/data)
|
||||
```
|
||||
|
||||
### Homogenized Product
|
||||
|
||||
```
|
||||
Scan QR → Load Batch #789 → Show:
|
||||
- Product Name
|
||||
- Batch Number
|
||||
- Test Date
|
||||
- Primary COA #3 (the homogenized product test) ← FEATURED
|
||||
- Source COAs:
|
||||
- COA #1 (flower source)
|
||||
- COA #2 (rosin source)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Manufacturing Workflow Examples
|
||||
|
||||
### Example 1: Selling Flower (No Conversion)
|
||||
|
||||
```
|
||||
1. Receive 500 lbs flower → Create Component Batch #123
|
||||
2. Send sample to lab → Receive COA #1 → Link to Batch #123
|
||||
3. Create SKUs:
|
||||
- Product: "Alien Market Flower" (parent)
|
||||
- SKU 1: "Alien Market Flower - 1/8oz" (variant, product_id=101)
|
||||
- SKU 2: "Alien Market Flower - 1/4oz" (variant, product_id=102)
|
||||
4. Link SKUs to Batch:
|
||||
- product_batches: product_id=101, batch_id=123
|
||||
- product_batches: product_id=102, batch_id=123
|
||||
5. Sell inventory:
|
||||
- SKU 1 sells 100 units (12.5 lbs) → Batch #123: 487.5 lbs remaining
|
||||
- SKU 2 sells 50 units (12.5 lbs) → Batch #123: 475 lbs remaining
|
||||
6. When Batch #123 depletes → Create NEW Batch #124 with NEW COA
|
||||
```
|
||||
|
||||
### Example 2: Homogenized Prerolls
|
||||
|
||||
```
|
||||
1. Have existing:
|
||||
- Component Batch #123: Flower (COA #1)
|
||||
- Component Batch #456: Rosin (COA #2)
|
||||
|
||||
2. Manufacturing:
|
||||
- Mix 10 lbs flower + 2 lbs rosin
|
||||
- Create 500 enhanced prerolls
|
||||
|
||||
3. Testing:
|
||||
- Send sample to lab → Receive COA #3
|
||||
|
||||
4. Create Homogenized Batch:
|
||||
- Create Batch #789 (type='homogenized', primary_coa_id=3)
|
||||
- Link sources:
|
||||
- batch_source_components: homogenized=789, source=123, qty=10 lbs
|
||||
- batch_source_components: homogenized=789, source=456, qty=2 lbs
|
||||
|
||||
5. Create SKUs:
|
||||
- Product: "Enhanced Preroll" (parent)
|
||||
- SKU 1: "Enhanced Preroll - 1pk" (variant, product_id=201)
|
||||
- SKU 2: "Enhanced Preroll - 5pk" (variant, product_id=202)
|
||||
|
||||
6. Link SKUs to Homogenized Batch:
|
||||
- product_batches: product_id=201, batch_id=789
|
||||
- product_batches: product_id=202, batch_id=789
|
||||
|
||||
7. QR Code Scan:
|
||||
- Shows COA #3 (primary)
|
||||
- Shows COA #1 + COA #2 (sources, via batch_source_components)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Mistakes to Avoid
|
||||
|
||||
❌ **WRONG:** Linking batches to parent products
|
||||
✅ **RIGHT:** Link batches to SKU variants only
|
||||
|
||||
❌ **WRONG:** Reusing COAs for new batches
|
||||
✅ **RIGHT:** New batch = new test = new COA
|
||||
|
||||
❌ **WRONG:** Testing rosin used internally
|
||||
✅ **RIGHT:** Only test rosin if selling it as a component to buyers
|
||||
|
||||
❌ **WRONG:** Not testing homogenized products
|
||||
✅ **RIGHT:** Mixed components ALWAYS require new test
|
||||
|
||||
❌ **WRONG:** Showing only homogenized COA on QR code
|
||||
✅ **RIGHT:** Show homogenized COA + all source component COAs
|
||||
|
||||
---
|
||||
|
||||
## Decision Summary for Worktree
|
||||
|
||||
**The current worktree's batch schema is FUNDAMENTALLY WRONG because:**
|
||||
|
||||
1. ❌ Links `batches.product_id` to products (could be parent OR variant)
|
||||
2. ❌ Doesn't distinguish component batches vs homogenized batches
|
||||
3. ❌ Doesn't track source components for homogenized products
|
||||
4. ❌ No way to show multiple COAs on QR code for homogenized products
|
||||
|
||||
**Recommended Action:**
|
||||
|
||||
1. Reject the current worktree architecture
|
||||
2. Design from scratch using THIS document as the spec
|
||||
3. Create new migrations that implement the schema above
|
||||
4. Build UI that handles both component and homogenized batch workflows
|
||||
|
||||
---
|
||||
|
||||
**This document is the single source of truth. Do not deviate without updating this file first.**
|
||||
92
CLAUDE.md
92
CLAUDE.md
@@ -10,12 +10,36 @@
|
||||
**Models needing business_id:** Component, Brand, Product, Driver, Vehicle, Contact, Invoice
|
||||
**Exception:** Orders span buyer + seller businesses - use `whereHas('items.product.brand')`
|
||||
|
||||
### 2. Route Prefixes
|
||||
### 2. Route Prefixes & URL Structure
|
||||
Check `docs/URL_STRUCTURE.md` BEFORE route changes.
|
||||
- `/b/*` → Buyers only
|
||||
- `/s/*` → Sellers only
|
||||
- `/admin` → Super admins only
|
||||
|
||||
**CRITICAL: URL Identifier Rules**
|
||||
- **Businesses**: ALWAYS use `slug` (e.g., `cannabrands`)
|
||||
- **Brands**: ALWAYS use `slug` (e.g., `hash-factory`, `aloha-tymemachine`)
|
||||
- **Products**: ALWAYS use `hashid` (e.g., `86qh2`, `52kn5`)
|
||||
- **Hashids are for products ONLY** - never use for businesses or brands
|
||||
|
||||
**Standard Brand URL Patterns:**
|
||||
```
|
||||
Seller (managing their brands):
|
||||
/s/cannabrands/brands/hash-factory/view → View brand details
|
||||
/s/cannabrands/brands/hash-factory/edit → Edit brand
|
||||
/s/cannabrands/brands/aloha-tymemachine/browse/preview → Preview menu
|
||||
|
||||
Buyer (browsing marketplace):
|
||||
/b/brands → All brands list
|
||||
/b/brands/hash-factory → View specific brand
|
||||
/b/brands/hash-factory/86qh2 → View product (brand-slug + product-hashid)
|
||||
/b/cannabrands/brands/hash-factory/browse → Browse seller's brand menu
|
||||
```
|
||||
|
||||
**Key Differences:**
|
||||
- Sellers manage: `/s/{business-slug}/brands/{brand-slug}/view|edit|preview`
|
||||
- Buyers browse: `/b/brands/{brand-slug}` or `/b/{business-slug}/brands/{brand-slug}/browse`
|
||||
|
||||
### 3. Filament Usage Boundary
|
||||
**Filament = `/admin` ONLY** (super admin tools)
|
||||
**DO NOT** use Filament for `/b/` or `/s/` - use DaisyUI + Blade instead
|
||||
@@ -121,3 +145,69 @@ Product::where('is_active', true)->get(); // No business_id filter!
|
||||
✅ DaisyUI for buyer/seller, Filament only for admin
|
||||
✅ NO inline styles - use Tailwind/DaisyUI classes only
|
||||
✅ Run tests before committing
|
||||
|
||||
---
|
||||
|
||||
## Analytics System
|
||||
|
||||
### How It Works
|
||||
Analytics tracking is **AUTOMATIC** on all buyer and public pages:
|
||||
- `layouts/buyer-app-with-sidebar.blade.php` - All authenticated buyer pages
|
||||
- `layouts/guest.blade.php` - All public/guest pages (registration, etc.)
|
||||
|
||||
**For product pages, pass the product to enable engagement tracking:**
|
||||
```blade
|
||||
@include('partials.analytics', ['product' => $product])
|
||||
```
|
||||
|
||||
**For custom layouts, manually include:**
|
||||
```blade
|
||||
@include('partials.analytics')
|
||||
```
|
||||
|
||||
### What Gets Tracked Automatically
|
||||
Once included, the tracker automatically captures:
|
||||
- Page views and time on page
|
||||
- Scroll depth
|
||||
- Session data
|
||||
- Elements with `data-track-click` attribute
|
||||
|
||||
### Product Engagement Signals
|
||||
On product pages, also tracks:
|
||||
- Image zoom: `data-action="zoom-image"`
|
||||
- Video views: `data-action="watch-video"`
|
||||
- Spec downloads: `data-action="download-spec"`
|
||||
- Add to cart: `data-action="add-to-cart"`
|
||||
- Add to wishlist: `data-action="add-to-wishlist"`
|
||||
|
||||
### Adding Click Tracking
|
||||
```blade
|
||||
<button data-track-click
|
||||
data-track-type="button"
|
||||
data-track-id="cta-button"
|
||||
data-track-label="Request Quote">
|
||||
Request Quote
|
||||
</button>
|
||||
```
|
||||
|
||||
### Analytics Dashboard Routes
|
||||
- `/s/{business}/analytics` - Overview dashboard
|
||||
- `/s/{business}/analytics/products` - Product analytics
|
||||
- `/s/{business}/analytics/buyers` - Buyer intelligence
|
||||
- `/s/{business}/analytics/marketing` - Email campaigns
|
||||
- `/s/{business}/analytics/sales` - Sales pipeline
|
||||
|
||||
### Key Files
|
||||
- **Tracker**: `resources/views/partials/analytics.blade.php`
|
||||
- **Controllers**: `app/Http/Controllers/Analytics/*`
|
||||
- **Models**: `app/Models/Analytics/*`
|
||||
- **Service**: `app/Services/AnalyticsTracker.php`
|
||||
|
||||
### Business Scoping
|
||||
All analytics queries use explicit `forBusiness($businessId)` scoping:
|
||||
```php
|
||||
ProductView::forBusiness($business->id)->where(...)->get();
|
||||
BuyerEngagementScore::forBusiness($business->id)->highValue()->get();
|
||||
```
|
||||
|
||||
**See**: `ANALYTICS_QUICK_START.md` for detailed implementation examples
|
||||
|
||||
265
CLAUDE_CONTEXT.md
Normal file
265
CLAUDE_CONTEXT.md
Normal file
@@ -0,0 +1,265 @@
|
||||
# 🤖 Claude Code - Critical Context
|
||||
|
||||
**READ THIS FIRST before starting ANY work on this codebase.**
|
||||
|
||||
This file contains the architectural decisions, security patterns, and common mistakes that you MUST understand before making changes.
|
||||
|
||||
---
|
||||
|
||||
## 📚 Quick Navigation
|
||||
|
||||
- **[CLAUDE.md](CLAUDE.md)** - Common mistakes and critical rules (READ EVERY TIME)
|
||||
- **[docs/README.md](docs/README.md)** - Full documentation index
|
||||
- **[docs/architecture/](docs/architecture/)** - System architecture and design decisions
|
||||
- **[docs/features/](docs/features/)** - Feature implementation guides
|
||||
|
||||
---
|
||||
|
||||
## 🚨 MOST CRITICAL RULES (Read Every Session)
|
||||
|
||||
### 1. Business Isolation Pattern (Security-Critical!)
|
||||
|
||||
```php
|
||||
// ❌ WRONG - Vulnerable to cross-tenant data access
|
||||
$component = Component::findOrFail($id);
|
||||
if ($component->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
// ✅ RIGHT - Scope BEFORE finding
|
||||
$component = Component::where('business_id', $business->id)->findOrFail($id);
|
||||
```
|
||||
|
||||
**Why:** This prevents ID enumeration attacks across tenants. Always scope by `business_id` BEFORE querying.
|
||||
|
||||
**Models requiring business_id scoping:**
|
||||
- Component, Brand, Product, Driver, Vehicle, Contact, Invoice
|
||||
- ALL Analytics models (ProductView, ClickTracking, etc.)
|
||||
|
||||
**Exception:** Orders span buyer + seller businesses:
|
||||
```php
|
||||
// Buyer viewing their orders
|
||||
Order::where('business_id', $business->id)->get();
|
||||
|
||||
// Seller viewing incoming orders
|
||||
Order::whereHas('items.product.brand', fn($q) => $q->where('business_id', $business->id))->get();
|
||||
```
|
||||
|
||||
### 2. URL Identifier Rules
|
||||
|
||||
| Resource | Identifier | Example |
|
||||
|----------|-----------|---------|
|
||||
| Business | `slug` | `/s/cannabrands/` |
|
||||
| Brand | `slug` | `/brands/hash-factory/` |
|
||||
| Product | `hashid` | `/products/86qh2` |
|
||||
|
||||
**NEVER mix these up!** Products use hashids, everything else uses slugs.
|
||||
|
||||
### 3. NO Global Scopes
|
||||
|
||||
```php
|
||||
// ❌ We do NOT use:
|
||||
protected static function booted() {
|
||||
static::addGlobalScope(new BusinessScope);
|
||||
}
|
||||
|
||||
// ✅ We use explicit scoping:
|
||||
ProductView::where('business_id', $business->id)->get();
|
||||
|
||||
// ✅ Or scope methods:
|
||||
ProductView::forBusiness($business->id)->get();
|
||||
```
|
||||
|
||||
**Why:** Two-sided marketplace needs cross-business queries (buyers browse all sellers' products). Global scopes would break the marketplace.
|
||||
|
||||
### 4. Permission System
|
||||
|
||||
```php
|
||||
// ✅ Use helper function
|
||||
hasBusinessPermission('analytics.overview')
|
||||
|
||||
// ❌ NOT Spatie's can() yet
|
||||
auth()->user()->can('analytics.overview') // Don't use
|
||||
```
|
||||
|
||||
Permissions stored in: `business_user` pivot table, `permissions` JSON column.
|
||||
|
||||
### 5. NO Inline Styles
|
||||
|
||||
```php
|
||||
// ❌ WRONG
|
||||
<div style="background-color: #3b82f6; padding: 1rem;">
|
||||
|
||||
// ✅ RIGHT
|
||||
<div class="bg-primary p-4">
|
||||
```
|
||||
|
||||
**Exception:** Only for truly dynamic database values (e.g., user-uploaded brand colors).
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architecture Overview
|
||||
|
||||
### Tech Stack by Area
|
||||
|
||||
| Area | Framework | Users | UI |
|
||||
|------|-----------|-------|-----|
|
||||
| `/admin` | Filament v3 | Super admins | Filament tables/forms |
|
||||
| `/b/` | Blade + DaisyUI | Buyers | Custom marketplace |
|
||||
| `/s/` | Blade + DaisyUI | Sellers | Custom CRM |
|
||||
|
||||
### Business Types
|
||||
|
||||
- `'buyer'` - Dispensary (browses marketplace, places orders)
|
||||
- `'seller'` - Brand (manages products, fulfills orders)
|
||||
- `'both'` - Vertically integrated
|
||||
|
||||
Users have `user_type` matching their business type.
|
||||
|
||||
### Multi-Business Users
|
||||
|
||||
```php
|
||||
// Users can belong to MULTIPLE businesses
|
||||
auth()->user()->businesses // BelongsToMany
|
||||
|
||||
// Get current business:
|
||||
auth()->user()->primaryBusiness()
|
||||
|
||||
// Or use helpers:
|
||||
currentBusiness()
|
||||
currentBusinessId()
|
||||
hasBusinessPermission($permission)
|
||||
```
|
||||
|
||||
### Product → Brand → Business Hierarchy
|
||||
|
||||
```php
|
||||
// Products DON'T have direct business_id
|
||||
$product->brand->business_id
|
||||
|
||||
// For tracking, get seller's business:
|
||||
$sellerBusiness = BusinessHelper::fromProduct($product);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security Checklist
|
||||
|
||||
Before committing ANY code that touches multi-tenant data:
|
||||
|
||||
- [ ] Every query scopes by `business_id` BEFORE finding records
|
||||
- [ ] Routes protected with proper middleware (`auth`, `verified`, `buyer`/`seller`)
|
||||
- [ ] Permission checks use `hasBusinessPermission()` helper
|
||||
- [ ] URL identifiers correct (slugs vs hashids)
|
||||
- [ ] Tests verify business isolation
|
||||
- [ ] No global scopes added
|
||||
- [ ] NO inline styles (use Tailwind/DaisyUI)
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing & Git
|
||||
|
||||
### Before Every Commit
|
||||
|
||||
```bash
|
||||
php artisan test --parallel # REQUIRED
|
||||
./vendor/bin/pint # REQUIRED
|
||||
```
|
||||
|
||||
### Test Credentials
|
||||
|
||||
- `buyer@example.com` / `password`
|
||||
- `seller@example.com` / `password`
|
||||
- `admin@example.com` / `password`
|
||||
|
||||
### Git Workflow
|
||||
|
||||
- Never commit directly to `master`/`develop`
|
||||
- Use feature branches: `feature/analytics-system`
|
||||
- CI/CD: Woodpecker checks syntax → Pint → tests → Docker build
|
||||
|
||||
---
|
||||
|
||||
## 📋 Common Query Patterns
|
||||
|
||||
```php
|
||||
// Seller viewing their products
|
||||
Product::whereHas('brand', fn($q) => $q->where('business_id', $business->id))->get();
|
||||
|
||||
// Buyer viewing their orders
|
||||
Order::where('business_id', $business->id)->get();
|
||||
|
||||
// Seller viewing incoming orders
|
||||
Order::whereHas('items.product.brand', fn($q) => $q->where('business_id', $business->id))->get();
|
||||
|
||||
// Marketplace (cross-business - intentional!)
|
||||
Product::where('is_active', true)->get(); // No business_id filter!
|
||||
|
||||
// Analytics scoping
|
||||
ProductView::where('business_id', $business->id)
|
||||
->whereBetween('viewed_at', [now()->subDays(30), now()])
|
||||
->get();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📖 Detailed Documentation
|
||||
|
||||
For in-depth information, see:
|
||||
|
||||
### Architecture
|
||||
- [docs/architecture/DATABASE.md](docs/architecture/DATABASE.md) - Database schema and relationships
|
||||
- [docs/architecture/DATABASE_STRATEGY.md](docs/architecture/DATABASE_STRATEGY.md) - Multi-tenancy strategy
|
||||
- [docs/architecture/URL_STRUCTURE.md](docs/architecture/URL_STRUCTURE.md) - URL patterns and routing
|
||||
- [docs/architecture/API.md](docs/architecture/API.md) - API design
|
||||
|
||||
### Features
|
||||
- [docs/features/ANALYTICS.md](docs/features/ANALYTICS.md) - Analytics System implementation
|
||||
- [docs/features/FILE_STORAGE.md](docs/features/FILE_STORAGE.md) - File storage and product images
|
||||
- [docs/features/BATCH_SYSTEM.md](docs/features/BATCH_SYSTEM.md) - Batch processing
|
||||
- [docs/features/MANUFACTURING.md](docs/features/MANUFACTURING.md) - Manufacturing workflows
|
||||
|
||||
### Development
|
||||
- [docs/development/SETUP.md](docs/development/SETUP.md) - Initial setup
|
||||
- [docs/development/LOCAL_DEV.md](docs/development/LOCAL_DEV.md) - Local development
|
||||
- [docs/development/DOCKER.md](docs/development/DOCKER.md) - Docker configuration
|
||||
|
||||
---
|
||||
|
||||
## 🎯 What You Often Forget
|
||||
|
||||
1. ✅ Scope by business_id BEFORE finding by ID
|
||||
2. ✅ Use Eloquent (never raw SQL)
|
||||
3. ✅ Protect routes with middleware
|
||||
4. ✅ DaisyUI for buyer/seller UI (NOT Filament)
|
||||
5. ✅ NO inline styles - Tailwind/DaisyUI classes only
|
||||
6. ✅ Run tests before committing
|
||||
7. ✅ Check URL identifier types (slug vs hashid)
|
||||
8. ✅ Products go through Brand to get business_id
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Common Issues
|
||||
|
||||
### "business_id cannot be null"
|
||||
**Solution:** Make sure `currentBusinessId()` returns a value. User must be logged in and have a business.
|
||||
|
||||
### "Seeing other businesses' data"
|
||||
**Solution:** You forgot to scope by business_id! Every query must have `where('business_id', ...)`.
|
||||
|
||||
### "Permission check not working"
|
||||
**Solution:** Check `business_user.permissions` JSON array. Use `hasBusinessPermission()` helper.
|
||||
|
||||
### "Product has no business_id"
|
||||
**Solution:** Products don't have direct business_id. Use `$product->brand->business_id` or `BusinessHelper::fromProduct($product)`.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Ready to Code!
|
||||
|
||||
Now read:
|
||||
1. [CLAUDE.md](CLAUDE.md) for detailed rules and examples
|
||||
2. [docs/README.md](docs/README.md) for full documentation index
|
||||
3. The relevant feature guide in [docs/features/](docs/features/)
|
||||
|
||||
**Remember:** When in doubt, CHECK the business_id scoping! It's the #1 security issue in this codebase.
|
||||
501
QUICK-HANDOFF-CLAUDE-CODE.md
Normal file
501
QUICK-HANDOFF-CLAUDE-CODE.md
Normal file
@@ -0,0 +1,501 @@
|
||||
# Analytics Implementation - Quick Handoff for Claude Code
|
||||
|
||||
## 🎯 Implementation Guide Location
|
||||
|
||||
**Main Technical Guide:** `/mnt/user-data/outputs/analytics-implementation-guide-REVISED.md`
|
||||
|
||||
This is a **REVISED** implementation that matches your ACTUAL Cannabrands architecture.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ CRITICAL ARCHITECTURAL DIFFERENCES
|
||||
|
||||
Your setup is different from typical Laravel multi-tenant apps:
|
||||
|
||||
### 1. **business_id is bigInteger (not UUID)**
|
||||
```php
|
||||
// Migration
|
||||
$table->unsignedBigInteger('business_id')->index();
|
||||
$table->foreign('business_id')->references('id')->on('businesses');
|
||||
|
||||
// NOT UUID like:
|
||||
$table->uuid('tenant_id');
|
||||
```
|
||||
|
||||
### 2. **NO Global Scopes - Explicit Scoping Pattern**
|
||||
```php
|
||||
// ❌ WRONG - Security vulnerability!
|
||||
ProductView::findOrFail($id)
|
||||
|
||||
// ✅ RIGHT - Your pattern
|
||||
ProductView::where('business_id', $business->id)->findOrFail($id)
|
||||
|
||||
// All queries MUST explicitly scope by business_id
|
||||
```
|
||||
|
||||
### 3. **Permissions in business_user.permissions JSON Column**
|
||||
```php
|
||||
// NOT using Spatie permission routes yet
|
||||
// Permissions stored in: business_user pivot table
|
||||
// Column: 'permissions' => 'array' (JSON)
|
||||
|
||||
// Check permissions via helper:
|
||||
hasBusinessPermission('analytics.overview')
|
||||
|
||||
// NOT via:
|
||||
auth()->user()->can('analytics.overview') // ❌ Don't use this yet
|
||||
```
|
||||
|
||||
### 4. **Multi-Business Users**
|
||||
```php
|
||||
// Users can belong to MULTIPLE businesses
|
||||
auth()->user()->businesses // BelongsToMany
|
||||
|
||||
// Get current business:
|
||||
auth()->user()->primaryBusiness()
|
||||
|
||||
// Or use helper:
|
||||
currentBusiness()
|
||||
currentBusinessId()
|
||||
```
|
||||
|
||||
### 5. **Products → Brand → Business Hierarchy**
|
||||
```php
|
||||
// Products DON'T have direct business_id
|
||||
// They go through Brand:
|
||||
$product->brand->business_id
|
||||
|
||||
// For tracking product views, get seller's business:
|
||||
$sellerBusiness = BusinessHelper::fromProduct($product);
|
||||
```
|
||||
|
||||
### 6. **User Types via Middleware**
|
||||
```php
|
||||
// Routes use user_type middleware:
|
||||
Route::middleware(['auth', 'verified', 'buyer']) // Buyers
|
||||
Route::middleware(['auth', 'verified', 'seller']) // Sellers
|
||||
Route::middleware(['auth', 'admin']) // Admins
|
||||
|
||||
// user_type values:
|
||||
'buyer' => 'Buyer/Retailer'
|
||||
'seller' => 'Seller/Brand'
|
||||
'admin' => 'Super Admin'
|
||||
```
|
||||
|
||||
### 7. **Reverb IS Configured (Horizon is NOT)**
|
||||
```php
|
||||
// ✅ Use Reverb for real-time updates
|
||||
use App\Events\Analytics\HighIntentBuyerDetected;
|
||||
event(new HighIntentBuyerDetected(...));
|
||||
|
||||
// ✅ Use Redis queues (already available)
|
||||
CalculateEngagementScore::dispatch()->onQueue('analytics');
|
||||
|
||||
// ❌ Don't install Horizon (not needed yet)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 WHAT YOU'RE BUILDING
|
||||
|
||||
### Database Tables (7 migrations):
|
||||
1. `analytics_events` - Raw event stream
|
||||
2. `product_views` - Product engagement tracking
|
||||
3. `email_campaigns` + `email_interactions` + `email_clicks` - Email tracking
|
||||
4. `click_tracking` - General click events
|
||||
5. `user_sessions` + `intent_signals` - Session & intent tracking
|
||||
6. `buyer_engagement_scores` - Calculated buyer scores
|
||||
7. `jobs` table for Redis queues
|
||||
|
||||
**Key Field:** Every table has `business_id` (bigInteger) with proper indexing
|
||||
|
||||
### Backend Components:
|
||||
- **Helper Functions:** `currentBusiness()`, `hasBusinessPermission()`
|
||||
- **AnalyticsTracker Service:** Main tracking service
|
||||
- **Queue Jobs:** Async engagement score calculations
|
||||
- **Events:** Reverb broadcasting for real-time updates
|
||||
- **Controllers:** Dashboard, Products, Marketing, Sales, Buyers
|
||||
- **Models:** 10 analytics models with explicit business scoping
|
||||
|
||||
### Frontend:
|
||||
- Permission management UI in existing business/users section
|
||||
- Analytics navigation (new top-level section)
|
||||
- Dashboard views with KPIs and charts
|
||||
- Real-time notifications via Reverb
|
||||
|
||||
---
|
||||
|
||||
## 🔐 SECURITY PATTERN (CRITICAL!)
|
||||
|
||||
**EVERY query MUST scope by business_id:**
|
||||
|
||||
```php
|
||||
// ❌ NEVER do this - data leakage!
|
||||
AnalyticsEvent::find($id)
|
||||
ProductView::where('product_id', $productId)->get()
|
||||
|
||||
// ✅ ALWAYS do this - business isolated
|
||||
AnalyticsEvent::where('business_id', $business->id)->find($id)
|
||||
ProductView::where('business_id', $business->id)
|
||||
->where('product_id', $productId)
|
||||
->get()
|
||||
|
||||
// ✅ Or use scope helper in models
|
||||
ProductView::forBusiness($business->id)->get()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 IMPLEMENTATION STEPS
|
||||
|
||||
### 1. Create Helper Files First
|
||||
```bash
|
||||
# Create helpers
|
||||
mkdir -p app/Helpers
|
||||
# Copy BusinessHelper.php
|
||||
# Copy helpers.php
|
||||
# Update composer.json autoload.files
|
||||
composer dump-autoload
|
||||
```
|
||||
|
||||
### 2. Run Migrations
|
||||
```bash
|
||||
# Copy all 7 migration files
|
||||
php artisan migrate
|
||||
|
||||
# Verify tables created
|
||||
php artisan tinker
|
||||
>>> DB::table('analytics_events')->count()
|
||||
>>> DB::table('product_views')->count()
|
||||
```
|
||||
|
||||
### 3. Create Models
|
||||
```bash
|
||||
mkdir -p app/Models/Analytics
|
||||
# Copy all model files (10 models)
|
||||
# Each model has explicit business scoping
|
||||
```
|
||||
|
||||
### 4. Create Services
|
||||
```bash
|
||||
mkdir -p app/Services/Analytics
|
||||
# Copy AnalyticsTracker service
|
||||
```
|
||||
|
||||
### 5. Create Jobs
|
||||
```bash
|
||||
mkdir -p app/Jobs/Analytics
|
||||
# Copy CalculateEngagementScore job
|
||||
```
|
||||
|
||||
### 6. Create Events
|
||||
```bash
|
||||
mkdir -p app/Events/Analytics
|
||||
# Copy HighIntentBuyerDetected event
|
||||
# Update routes/channels.php for broadcasting
|
||||
```
|
||||
|
||||
### 7. Create Controllers
|
||||
```bash
|
||||
mkdir -p app/Http/Controllers/Analytics
|
||||
# Copy all controller files
|
||||
```
|
||||
|
||||
### 8. Add Routes
|
||||
```bash
|
||||
# Update routes/web.php with analytics routes
|
||||
# Use existing middleware patterns (auth, verified)
|
||||
```
|
||||
|
||||
### 9. Update UI
|
||||
```bash
|
||||
# Add analytics navigation section
|
||||
# Add permission management tile to business/users
|
||||
# Create analytics dashboard views
|
||||
```
|
||||
|
||||
### 10. Configure Queues
|
||||
```bash
|
||||
# Start queue worker
|
||||
php artisan queue:work --queue=analytics
|
||||
|
||||
# (Reverb should already be running)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 TRACKING EXAMPLES
|
||||
|
||||
### Track Product View
|
||||
```php
|
||||
use App\Services\Analytics\AnalyticsTracker;
|
||||
|
||||
public function show(Product $product, Request $request)
|
||||
{
|
||||
$tracker = new AnalyticsTracker($request);
|
||||
$view = $tracker->trackProductView($product);
|
||||
|
||||
// Queue engagement score calculation if buyer
|
||||
if ($view && $view->buyer_business_id) {
|
||||
\App\Jobs\Analytics\CalculateEngagementScore::dispatch(
|
||||
$view->business_id,
|
||||
$view->buyer_business_id
|
||||
);
|
||||
}
|
||||
|
||||
return view('products.show', compact('product'));
|
||||
}
|
||||
```
|
||||
|
||||
### JavaScript Click Tracking
|
||||
```javascript
|
||||
// Add to your main JS
|
||||
document.addEventListener('click', function(e) {
|
||||
const trackable = e.target.closest('[data-track-click]');
|
||||
if (trackable) {
|
||||
fetch('/api/analytics/track-click', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
|
||||
},
|
||||
body: JSON.stringify({
|
||||
element_type: trackable.dataset.trackClick,
|
||||
element_id: trackable.dataset.trackId
|
||||
})
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### HTML Usage
|
||||
```blade
|
||||
<a href="{{ route('products.show', $product) }}"
|
||||
data-track-click="product_link"
|
||||
data-track-id="{{ $product->id }}">
|
||||
{{ $product->name }}
|
||||
</a>
|
||||
```
|
||||
|
||||
### Real-Time Notifications
|
||||
```javascript
|
||||
// In analytics dashboard
|
||||
const businessId = {{ $business->id }};
|
||||
|
||||
Echo.channel('analytics.business.' + businessId)
|
||||
.listen('.high-intent-buyer', (e) => {
|
||||
showNotification('🔥 Hot Lead!', `${e.buyer_name} showing high intent`);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 TESTING BUSINESS ISOLATION
|
||||
|
||||
```php
|
||||
// In php artisan tinker
|
||||
|
||||
// 1. Login as user
|
||||
auth()->loginUsingId(1);
|
||||
$business = currentBusiness();
|
||||
|
||||
// 2. Test helper
|
||||
echo "Business ID: " . currentBusinessId();
|
||||
|
||||
// 3. Test permission
|
||||
echo hasBusinessPermission('analytics.overview') ? "✅ HAS" : "❌ NO";
|
||||
|
||||
// 4. Test scoping - should only return current business data
|
||||
$count = App\Models\Analytics\ProductView::where('business_id', $business->id)->count();
|
||||
echo "My views: $count";
|
||||
|
||||
// 5. Test auto-set business_id
|
||||
$event = App\Models\Analytics\AnalyticsEvent::create([
|
||||
'event_type' => 'test'
|
||||
]);
|
||||
echo $event->business_id === $business->id ? "✅ PASS" : "❌ FAIL";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 PERMISSION SETUP
|
||||
|
||||
Add permissions to a user:
|
||||
|
||||
```php
|
||||
// In tinker or seeder
|
||||
$user = User::find(1);
|
||||
$business = $user->businesses->first();
|
||||
|
||||
// Grant analytics permissions
|
||||
$user->businesses()->updateExistingPivot($business->id, [
|
||||
'permissions' => [
|
||||
'analytics.overview',
|
||||
'analytics.products',
|
||||
'analytics.marketing',
|
||||
'analytics.sales',
|
||||
'analytics.buyers',
|
||||
'analytics.export'
|
||||
]
|
||||
]);
|
||||
|
||||
// Verify
|
||||
$pivot = $user->businesses()->find($business->id)->pivot;
|
||||
print_r($pivot->permissions);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚡ QUEUE CONFIGURATION
|
||||
|
||||
Make sure Redis is running and queue worker is started:
|
||||
|
||||
```bash
|
||||
# Check Redis
|
||||
redis-cli ping
|
||||
|
||||
# Start queue worker
|
||||
php artisan queue:work --queue=analytics --tries=3
|
||||
|
||||
# Or with supervisor (production):
|
||||
[program:cannabrands-analytics-queue]
|
||||
command=php /path/to/artisan queue:work --queue=analytics --tries=3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 NAVIGATION UPDATE
|
||||
|
||||
Add to your sidebar navigation:
|
||||
|
||||
```blade
|
||||
<!-- Analytics Section (New Top-Level) -->
|
||||
<div class="nav-section">
|
||||
<div class="nav-header">
|
||||
<svg>...</svg>
|
||||
Analytics
|
||||
</div>
|
||||
|
||||
@if(hasBusinessPermission('analytics.overview'))
|
||||
<a href="{{ route('analytics.dashboard') }}" class="nav-item">
|
||||
Overview
|
||||
</a>
|
||||
@endif
|
||||
|
||||
@if(hasBusinessPermission('analytics.products'))
|
||||
<a href="{{ route('analytics.products.index') }}" class="nav-item">
|
||||
Products
|
||||
</a>
|
||||
@endif
|
||||
|
||||
<!-- Marketing, Sales, Buyers... -->
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐛 COMMON ISSUES
|
||||
|
||||
### Issue: "business_id cannot be null"
|
||||
**Solution:** Make sure `currentBusinessId()` returns a value. User must be logged in and have a business.
|
||||
|
||||
### Issue: "Seeing other businesses' data"
|
||||
**Solution:** You forgot to scope by business_id! Check your query has `where('business_id', ...)`.
|
||||
|
||||
### Issue: "Permission check not working"
|
||||
**Solution:** Check the permissions array in business_user pivot table. Make sure it's a JSON array.
|
||||
|
||||
### Issue: "Product has no business_id"
|
||||
**Solution:** Products don't have direct business_id. Use `BusinessHelper::fromProduct($product)` to get seller's business.
|
||||
|
||||
---
|
||||
|
||||
## 📚 FILE STRUCTURE
|
||||
|
||||
```
|
||||
app/
|
||||
├── Events/Analytics/
|
||||
│ └── HighIntentBuyerDetected.php
|
||||
├── Helpers/
|
||||
│ ├── BusinessHelper.php
|
||||
│ └── helpers.php
|
||||
├── Http/Controllers/Analytics/
|
||||
│ ├── AnalyticsDashboardController.php
|
||||
│ ├── ProductAnalyticsController.php
|
||||
│ ├── MarketingAnalyticsController.php
|
||||
│ ├── SalesAnalyticsController.php
|
||||
│ └── BuyerIntelligenceController.php
|
||||
├── Jobs/Analytics/
|
||||
│ └── CalculateEngagementScore.php
|
||||
├── Models/Analytics/
|
||||
│ ├── AnalyticsEvent.php
|
||||
│ ├── ProductView.php
|
||||
│ ├── EmailCampaign.php
|
||||
│ ├── EmailInteraction.php
|
||||
│ ├── EmailClick.php
|
||||
│ ├── ClickTracking.php
|
||||
│ ├── UserSession.php
|
||||
│ ├── IntentSignal.php
|
||||
│ └── BuyerEngagementScore.php
|
||||
└── Services/Analytics/
|
||||
└── AnalyticsTracker.php
|
||||
|
||||
database/migrations/
|
||||
├── 2024_01_01_000001_create_analytics_events_table.php
|
||||
├── 2024_01_01_000002_create_product_views_table.php
|
||||
├── 2024_01_01_000003_create_email_tracking_tables.php
|
||||
├── 2024_01_01_000004_create_click_tracking_table.php
|
||||
├── 2024_01_01_000005_create_user_sessions_and_intent_tables.php
|
||||
├── 2024_01_01_000006_add_analytics_permissions_to_business_user.php
|
||||
└── 2024_01_01_000007_create_analytics_jobs_table.php
|
||||
|
||||
resources/views/analytics/
|
||||
├── dashboard.blade.php
|
||||
├── products/
|
||||
│ ├── index.blade.php
|
||||
│ └── show.blade.php
|
||||
├── marketing/
|
||||
├── sales/
|
||||
└── buyers/
|
||||
|
||||
routes/
|
||||
├── channels.php (add broadcasting channel)
|
||||
└── web.php (add analytics routes)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ DEFINITION OF DONE
|
||||
|
||||
- [ ] All 7 migrations run successfully
|
||||
- [ ] BusinessHelper and helpers.php created and autoloaded
|
||||
- [ ] All 10 analytics models created with business scoping
|
||||
- [ ] AnalyticsTracker service working
|
||||
- [ ] Queue jobs configured and tested
|
||||
- [ ] Reverb events broadcasting
|
||||
- [ ] All 5 controllers created
|
||||
- [ ] Routes added with permission checks
|
||||
- [ ] Navigation updated with Analytics section
|
||||
- [ ] Permission UI tile added
|
||||
- [ ] At least one dashboard view working
|
||||
- [ ] Business isolation verified (no cross-business data)
|
||||
- [ ] Permission checking works via business_user pivot
|
||||
- [ ] Queue worker running for analytics jobs
|
||||
- [ ] Test data can be created and viewed
|
||||
|
||||
---
|
||||
|
||||
## 🎉 READY TO IMPLEMENT!
|
||||
|
||||
Everything in the main guide is tailored to YOUR actual architecture:
|
||||
- ✅ business_id (bigInteger) not UUID
|
||||
- ✅ Explicit scoping, no global scopes
|
||||
- ✅ business_user.permissions JSON
|
||||
- ✅ Multi-business user support
|
||||
- ✅ Product → Brand → Business hierarchy
|
||||
- ✅ Reverb for real-time
|
||||
- ✅ Redis queues (no Horizon needed)
|
||||
|
||||
**Estimated implementation time: 5-6 hours**
|
||||
|
||||
Start with helpers and migrations, then build up from there! 🚀
|
||||
104
analyze-old-schema.php
Normal file
104
analyze-old-schema.php
Normal file
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
$sql = file_get_contents(__DIR__.'/hubexport.sql');
|
||||
|
||||
echo "=== ANALYZING OLD MYSQL DATABASE SCHEMA ===\n\n";
|
||||
|
||||
// Extract CREATE TABLE for brands
|
||||
if (preg_match('/CREATE TABLE `brands` \((.*?)\) ENGINE=/s', $sql, $match)) {
|
||||
echo "OLD MYSQL BRANDS TABLE COLUMNS:\n";
|
||||
$columns = $match[1];
|
||||
// Split by comma but not inside parentheses
|
||||
preg_match_all('/`(\w+)`\s+(\w+)(?:\(.*?\))?(?:\s+(.*?))?(?=,\s*`|\s*$)/s', $columns, $matches, PREG_SET_ORDER);
|
||||
foreach ($matches as $col) {
|
||||
$name = $col[1];
|
||||
$type = $col[2];
|
||||
$extra = isset($col[3]) ? trim($col[3]) : '';
|
||||
echo " - $name: $type $extra\n";
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
|
||||
// Extract CREATE TABLE for products
|
||||
if (preg_match('/CREATE TABLE `products` \((.*?)\) ENGINE=/s', $sql, $match)) {
|
||||
echo "OLD MYSQL PRODUCTS TABLE COLUMNS:\n";
|
||||
$columns = $match[1];
|
||||
preg_match_all('/`(\w+)`\s+(\w+)(?:\(.*?\))?(?:\s+(.*?))?(?=,\s*`|\s*$)/s', $columns, $matches, PREG_SET_ORDER);
|
||||
foreach (array_slice($matches, 0, 30) as $col) {
|
||||
$name = $col[1];
|
||||
$type = $col[2];
|
||||
$extra = isset($col[3]) ? trim($col[3]) : '';
|
||||
echo " - $name: $type $extra\n";
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
|
||||
// Extract CREATE TABLE for product_images
|
||||
if (preg_match('/CREATE TABLE `product_images` \((.*?)\) ENGINE=/s', $sql, $match)) {
|
||||
echo "OLD MYSQL PRODUCT_IMAGES TABLE COLUMNS:\n";
|
||||
$columns = $match[1];
|
||||
preg_match_all('/`(\w+)`\s+(\w+)(?:\(.*?\))?(?:\s+(.*?))?(?=,\s*`|\s*$)/s', $columns, $matches, PREG_SET_ORDER);
|
||||
foreach ($matches as $col) {
|
||||
$name = $col[1];
|
||||
$type = $col[2];
|
||||
$extra = isset($col[3]) ? trim($col[3]) : '';
|
||||
echo " - $name: $type $extra\n";
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
|
||||
// Extract CREATE TABLE for product_prices
|
||||
if (preg_match('/CREATE TABLE `product_prices` \((.*?)\) ENGINE=/s', $sql, $match)) {
|
||||
echo "OLD MYSQL PRODUCT_PRICES TABLE COLUMNS:\n";
|
||||
$columns = $match[1];
|
||||
preg_match_all('/`(\w+)`\s+(\w+)(?:\(.*?\))?(?:\s+(.*?))?(?=,\s*`|\s*$)/s', $columns, $matches, PREG_SET_ORDER);
|
||||
foreach ($matches as $col) {
|
||||
$name = $col[1];
|
||||
$type = $col[2];
|
||||
$extra = isset($col[3]) ? trim($col[3]) : '';
|
||||
echo " - $name: $type $extra\n";
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
|
||||
// Extract CREATE TABLE for product_variations
|
||||
if (preg_match('/CREATE TABLE `product_variations` \((.*?)\) ENGINE=/s', $sql, $match)) {
|
||||
echo "OLD MYSQL PRODUCT_VARIATIONS TABLE COLUMNS:\n";
|
||||
$columns = $match[1];
|
||||
preg_match_all('/`(\w+)`\s+(\w+)(?:\(.*?\))?(?:\s+(.*?))?(?=,\s*`|\s*$)/s', $columns, $matches, PREG_SET_ORDER);
|
||||
foreach ($matches as $col) {
|
||||
$name = $col[1];
|
||||
$type = $col[2];
|
||||
$extra = isset($col[3]) ? trim($col[3]) : '';
|
||||
echo " - $name: $type $extra\n";
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
|
||||
// Extract CREATE TABLE for labs
|
||||
if (preg_match('/CREATE TABLE `labs` \((.*?)\) ENGINE=/s', $sql, $match)) {
|
||||
echo "OLD MYSQL LABS TABLE COLUMNS:\n";
|
||||
$columns = $match[1];
|
||||
preg_match_all('/`(\w+)`\s+(\w+)(?:\(.*?\))?(?:\s+(.*?))?(?=,\s*`|\s*$)/s', $columns, $matches, PREG_SET_ORDER);
|
||||
foreach ($matches as $col) {
|
||||
$name = $col[1];
|
||||
$type = $col[2];
|
||||
$extra = isset($col[3]) ? trim($col[3]) : '';
|
||||
echo " - $name: $type $extra\n";
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
|
||||
// Extract CREATE TABLE for product_extras
|
||||
if (preg_match('/CREATE TABLE `product_extras` \((.*?)\) ENGINE=/s', $sql, $match)) {
|
||||
echo "OLD MYSQL PRODUCT_EXTRAS TABLE COLUMNS:\n";
|
||||
$columns = $match[1];
|
||||
preg_match_all('/`(\w+)`\s+(\w+)(?:\(.*?\))?(?:\s+(.*?))?(?=,\s*`|\s*$)/s', $columns, $matches, PREG_SET_ORDER);
|
||||
foreach ($matches as $col) {
|
||||
$name = $col[1];
|
||||
$type = $col[2];
|
||||
$extra = isset($col[3]) ? trim($col[3]) : '';
|
||||
echo " - $name: $type $extra\n";
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
155
app/Console/Commands/CleanupPermissionAuditLogs.php
Normal file
155
app/Console/Commands/CleanupPermissionAuditLogs.php
Normal file
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\PermissionAuditLog;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class CleanupPermissionAuditLogs extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'permissions:cleanup-audit
|
||||
{--dry-run : Show what would be deleted without actually deleting}
|
||||
{--force : Skip confirmation prompt}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Delete expired permission audit logs (non-critical logs past their expiration date)';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$isDryRun = $this->option('dry-run');
|
||||
$isForced = $this->option('force');
|
||||
|
||||
$this->info('🔍 Scanning for expired permission audit logs...');
|
||||
$this->newLine();
|
||||
|
||||
// Find expired logs
|
||||
$expiredLogs = PermissionAuditLog::expired()->get();
|
||||
|
||||
if ($expiredLogs->isEmpty()) {
|
||||
$this->info('✅ No expired audit logs found. Everything is up to date!');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// Statistics
|
||||
$totalCount = $expiredLogs->count();
|
||||
$oldestLog = $expiredLogs->sortBy('created_at')->first();
|
||||
$newestLog = $expiredLogs->sortByDesc('created_at')->first();
|
||||
|
||||
// Display summary
|
||||
$this->table(
|
||||
['Metric', 'Value'],
|
||||
[
|
||||
['Expired logs found', $totalCount],
|
||||
['Oldest expired log', $oldestLog->created_at->format('Y-m-d H:i:s')],
|
||||
['Newest expired log', $newestLog->created_at->format('Y-m-d H:i:s')],
|
||||
['Date range', $oldestLog->created_at->diffForHumans($newestLog->created_at, true)],
|
||||
]
|
||||
);
|
||||
|
||||
$this->newLine();
|
||||
|
||||
// Show sample of logs to be deleted
|
||||
$this->info('📋 Sample of logs to be deleted:');
|
||||
$sampleLogs = $expiredLogs->take(5);
|
||||
|
||||
foreach ($sampleLogs as $log) {
|
||||
$this->line(sprintf(
|
||||
' • [%s] %s - %s (expired %s)',
|
||||
$log->created_at->format('Y-m-d'),
|
||||
$log->action_name,
|
||||
$log->targetUser?->name ?? 'Unknown User',
|
||||
$log->expires_at->diffForHumans()
|
||||
));
|
||||
}
|
||||
|
||||
if ($totalCount > 5) {
|
||||
$this->line(" ... and {$totalCount} more");
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
|
||||
// Dry run mode
|
||||
if ($isDryRun) {
|
||||
$this->warn('🧪 DRY RUN MODE - No logs will be deleted');
|
||||
$this->info("Would delete {$totalCount} expired audit logs");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// Confirmation prompt (unless forced)
|
||||
if (! $isForced) {
|
||||
$confirmed = $this->confirm(
|
||||
"Are you sure you want to delete {$totalCount} expired audit logs?",
|
||||
false
|
||||
);
|
||||
|
||||
if (! $confirmed) {
|
||||
$this->info('❌ Cleanup cancelled');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
// Perform deletion
|
||||
$this->info('🗑️ Deleting expired audit logs...');
|
||||
|
||||
$progressBar = $this->output->createProgressBar($totalCount);
|
||||
$progressBar->start();
|
||||
|
||||
$deletedCount = 0;
|
||||
$errorCount = 0;
|
||||
|
||||
foreach ($expiredLogs as $log) {
|
||||
try {
|
||||
$log->delete();
|
||||
$deletedCount++;
|
||||
} catch (\Exception $e) {
|
||||
$errorCount++;
|
||||
$this->error("Failed to delete log ID {$log->id}: {$e->getMessage()}");
|
||||
}
|
||||
|
||||
$progressBar->advance();
|
||||
}
|
||||
|
||||
$progressBar->finish();
|
||||
$this->newLine(2);
|
||||
|
||||
// Final summary
|
||||
if ($errorCount === 0) {
|
||||
$this->info("✅ Successfully deleted {$deletedCount} expired audit logs");
|
||||
} else {
|
||||
$this->warn("⚠️ Deleted {$deletedCount} logs with {$errorCount} errors");
|
||||
}
|
||||
|
||||
// Show remaining stats
|
||||
$remainingTotal = PermissionAuditLog::count();
|
||||
$remainingCritical = PermissionAuditLog::critical()->count();
|
||||
$remainingNonExpired = $remainingTotal - $remainingCritical;
|
||||
|
||||
$this->newLine();
|
||||
$this->info('📊 Database statistics after cleanup:');
|
||||
$this->table(
|
||||
['Category', 'Count'],
|
||||
[
|
||||
['Critical logs (kept forever)', $remainingCritical],
|
||||
['Non-critical logs (not yet expired)', $remainingNonExpired],
|
||||
['Total remaining logs', $remainingTotal],
|
||||
]
|
||||
);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
56
app/Events/HighIntentBuyerDetected.php
Normal file
56
app/Events/HighIntentBuyerDetected.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Analytics\BuyerEngagementScore;
|
||||
use App\Models\Analytics\IntentSignal;
|
||||
use Illuminate\Broadcasting\Channel;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class HighIntentBuyerDetected implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public int $sellerBusinessId,
|
||||
public int $buyerBusinessId,
|
||||
public IntentSignal $signal,
|
||||
public ?BuyerEngagementScore $engagementScore = null
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the channels the event should broadcast on.
|
||||
*/
|
||||
public function broadcastOn(): Channel
|
||||
{
|
||||
return new Channel("business.{$this->sellerBusinessId}.analytics");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the data to broadcast.
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'buyer_business_id' => $this->buyerBusinessId,
|
||||
'buyer_business_name' => $this->signal->buyerBusiness?->name,
|
||||
'signal_type' => $this->signal->signal_type,
|
||||
'signal_strength' => $this->signal->signal_strength,
|
||||
'product_id' => $this->signal->subject_type === 'App\Models\Product' ? $this->signal->subject_id : null,
|
||||
'total_engagement_score' => $this->engagementScore?->total_score,
|
||||
'detected_at' => $this->signal->detected_at->toIso8601String(),
|
||||
'context' => $this->signal->context,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The event's broadcast name.
|
||||
*/
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'high-intent-buyer-detected';
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,22 @@ class UserResource extends Resource
|
||||
'suspended' => 'Suspended',
|
||||
])
|
||||
->default('active'),
|
||||
TextInput::make('password')
|
||||
->label('Password')
|
||||
->password()
|
||||
->required(fn ($record) => $record === null)
|
||||
->dehydrated(fn ($state) => filled($state))
|
||||
->minLength(8)
|
||||
->maxLength(255)
|
||||
->helperText('Leave blank to keep current password when editing')
|
||||
->visible(fn ($livewire) => $livewire instanceof CreateUser),
|
||||
TextInput::make('password_confirmation')
|
||||
->label('Confirm Password')
|
||||
->password()
|
||||
->required(fn ($record) => $record === null && filled($record?->password))
|
||||
->dehydrated(false)
|
||||
->same('password')
|
||||
->visible(fn ($livewire) => $livewire instanceof CreateUser),
|
||||
])->columns(2),
|
||||
|
||||
Section::make('Business Association')
|
||||
|
||||
@@ -4,8 +4,18 @@ namespace App\Filament\Resources\UserResource\Pages;
|
||||
|
||||
use App\Filament\Resources\UserResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class CreateUser extends CreateRecord
|
||||
{
|
||||
protected static string $resource = UserResource::class;
|
||||
|
||||
protected function mutateFormDataBeforeCreate(array $data): array
|
||||
{
|
||||
if (isset($data['password'])) {
|
||||
$data['password'] = Hash::make($data['password']);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
143
app/Helpers/BusinessHelper.php
Normal file
143
app/Helpers/BusinessHelper.php
Normal file
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
namespace App\Helpers;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Services\PermissionService;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class BusinessHelper
|
||||
{
|
||||
/**
|
||||
* Get current business context from session or user's primary business
|
||||
*/
|
||||
public static function current(): ?Business
|
||||
{
|
||||
if (! Auth::check()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$businessId = session('current_business_id');
|
||||
|
||||
if ($businessId) {
|
||||
return Business::find($businessId);
|
||||
}
|
||||
|
||||
// Fall back to user's primary business if no session is set
|
||||
return Auth::user()->primaryBusiness();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has a permission for current business
|
||||
*
|
||||
* This method now uses PermissionService internally for better architecture
|
||||
* while maintaining backward compatibility with existing code.
|
||||
*
|
||||
* @param string $permission Permission key (e.g. 'analytics.overview')
|
||||
*/
|
||||
public static function hasPermission(string $permission): bool
|
||||
{
|
||||
if (! Auth::check()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
$business = self::current();
|
||||
|
||||
if (! $business) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use PermissionService for permission checking
|
||||
$permissionService = app(PermissionService::class);
|
||||
|
||||
return $permissionService->check($user, $permission, $business);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is owner or admin for current business
|
||||
*/
|
||||
public static function isOwnerOrAdmin(): bool
|
||||
{
|
||||
if (! Auth::check()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
$business = self::current();
|
||||
|
||||
if (! $business) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Super admin
|
||||
if ($user->user_type === 'admin') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Business owner
|
||||
return $business->owner_user_id === $user->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's role template for current business
|
||||
*/
|
||||
public static function getRoleTemplate(): ?string
|
||||
{
|
||||
if (! Auth::check()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
$business = self::current();
|
||||
|
||||
if (! $business) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$businessUser = $user->businesses()
|
||||
->where('businesses.id', $business->id)
|
||||
->first();
|
||||
|
||||
return $businessUser?->pivot->role_template;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's permissions array for current business
|
||||
*/
|
||||
public static function getPermissions(): array
|
||||
{
|
||||
if (! Auth::check()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
$business = self::current();
|
||||
|
||||
if (! $business) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Use PermissionService for cached permission retrieval
|
||||
$permissionService = app(PermissionService::class);
|
||||
|
||||
return $permissionService->getUserPermissions($user, $business);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current business has a specific module enabled
|
||||
*
|
||||
* @param string $module Module name (sales, manufacturing, compliance)
|
||||
*/
|
||||
public static function hasModule(string $module): bool
|
||||
{
|
||||
$business = self::current();
|
||||
|
||||
return match ($module) {
|
||||
'sales' => true, // Sales is always enabled (base product)
|
||||
'manufacturing' => $business?->has_manufacturing ?? false,
|
||||
'compliance' => $business?->has_compliance ?? false,
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
}
|
||||
24
app/Helpers/helpers.php
Normal file
24
app/Helpers/helpers.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
use App\Helpers\BusinessHelper;
|
||||
|
||||
if (! function_exists('currentBusiness')) {
|
||||
function currentBusiness()
|
||||
{
|
||||
return BusinessHelper::current();
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('currentBusinessId')) {
|
||||
function currentBusinessId()
|
||||
{
|
||||
return BusinessHelper::currentId();
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('hasBusinessPermission')) {
|
||||
function hasBusinessPermission(string $permission): bool
|
||||
{
|
||||
return BusinessHelper::hasPermission($permission);
|
||||
}
|
||||
}
|
||||
101
app/Http/Controllers/Analytics/AnalyticsDashboardController.php
Normal file
101
app/Http/Controllers/Analytics/AnalyticsDashboardController.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Analytics;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Analytics\AnalyticsEvent;
|
||||
use App\Models\Analytics\BuyerEngagementScore;
|
||||
use App\Models\Analytics\IntentSignal;
|
||||
use App\Models\Analytics\ProductView;
|
||||
use App\Models\Analytics\UserSession;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AnalyticsDashboardController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
if (! hasBusinessPermission('analytics.overview')) {
|
||||
abort(403, 'Unauthorized to view analytics');
|
||||
}
|
||||
|
||||
$business = currentBusiness();
|
||||
$period = $request->input('period', '30'); // days
|
||||
|
||||
$startDate = now()->subDays((int) $period);
|
||||
|
||||
// Key metrics
|
||||
$metrics = [
|
||||
'total_sessions' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)->count(),
|
||||
'total_page_views' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)->sum('page_views'),
|
||||
'total_product_views' => ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)->count(),
|
||||
'unique_products_viewed' => ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)
|
||||
->distinct('product_id')
|
||||
->count('product_id'),
|
||||
'high_intent_signals' => IntentSignal::forBusiness($business->id)->where('detected_at', '>=', $startDate)
|
||||
->where('signal_strength', '>=', IntentSignal::STRENGTH_HIGH)
|
||||
->count(),
|
||||
'active_buyers' => BuyerEngagementScore::forBusiness($business->id)->where('last_interaction_at', '>=', $startDate)->count(),
|
||||
];
|
||||
|
||||
// Traffic trend (daily breakdown)
|
||||
$trafficTrend = AnalyticsEvent::forBusiness($business->id)->where('created_at', '>=', $startDate)
|
||||
->select(
|
||||
DB::raw('DATE(created_at) as date'),
|
||||
DB::raw('COUNT(*) as total_events'),
|
||||
DB::raw('COUNT(DISTINCT session_id) as unique_sessions')
|
||||
)
|
||||
->groupBy('date')
|
||||
->orderBy('date')
|
||||
->get();
|
||||
|
||||
// Top products by views
|
||||
$topProducts = ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)
|
||||
->select('product_id', DB::raw('COUNT(*) as view_count'))
|
||||
->groupBy('product_id')
|
||||
->orderByDesc('view_count')
|
||||
->limit(10)
|
||||
->with('product')
|
||||
->get();
|
||||
|
||||
// High-value buyers
|
||||
$highValueBuyers = BuyerEngagementScore::forBusiness($business->id)->highValue()
|
||||
->active()
|
||||
->orderByDesc('score')
|
||||
->limit(10)
|
||||
->with('buyerBusiness')
|
||||
->get();
|
||||
|
||||
// Recent high-intent signals
|
||||
$recentIntentSignals = IntentSignal::forBusiness($business->id)->highIntent()
|
||||
->where('detected_at', '>=', now()->subHours(24))
|
||||
->orderByDesc('detected_at')
|
||||
->limit(10)
|
||||
->with(['buyerBusiness', 'user'])
|
||||
->get();
|
||||
|
||||
// Engagement score distribution
|
||||
$engagementDistribution = BuyerEngagementScore::forBusiness($business->id)->select(
|
||||
DB::raw('CASE
|
||||
WHEN score >= 80 THEN \'Very High\'
|
||||
WHEN score >= 60 THEN \'High\'
|
||||
WHEN score >= 40 THEN \'Medium\'
|
||||
ELSE \'Low\'
|
||||
END as score_range'),
|
||||
DB::raw('COUNT(*) as count')
|
||||
)
|
||||
->groupBy('score_range')
|
||||
->get();
|
||||
|
||||
return view('seller.analytics.dashboard', compact(
|
||||
'business',
|
||||
'period',
|
||||
'metrics',
|
||||
'trafficTrend',
|
||||
'topProducts',
|
||||
'highValueBuyers',
|
||||
'recentIntentSignals',
|
||||
'engagementDistribution'
|
||||
));
|
||||
}
|
||||
}
|
||||
194
app/Http/Controllers/Analytics/BuyerIntelligenceController.php
Normal file
194
app/Http/Controllers/Analytics/BuyerIntelligenceController.php
Normal file
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Analytics;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Analytics\BuyerEngagementScore;
|
||||
use App\Models\Analytics\IntentSignal;
|
||||
use App\Models\Analytics\ProductView;
|
||||
use App\Models\Business;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class BuyerIntelligenceController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
// TODO: Re-enable when permission system is implemented
|
||||
// if (! hasBusinessPermission('analytics.buyers')) {
|
||||
// abort(403, 'Unauthorized to view buyer intelligence');
|
||||
// }
|
||||
|
||||
$business = currentBusiness();
|
||||
$period = $request->input('period', '30');
|
||||
$filter = $request->input('filter', 'all'); // all, high-value, at-risk, new
|
||||
$startDate = now()->subDays((int) $period);
|
||||
|
||||
// Overall buyer metrics
|
||||
$metrics = [
|
||||
'total_buyers' => BuyerEngagementScore::forBusiness($business->id)->count(),
|
||||
'active_buyers' => BuyerEngagementScore::forBusiness($business->id)->active()->count(),
|
||||
'high_value_buyers' => BuyerEngagementScore::forBusiness($business->id)->highValue()->count(),
|
||||
'at_risk_buyers' => BuyerEngagementScore::forBusiness($business->id)->atRisk()->count(),
|
||||
'new_buyers' => BuyerEngagementScore::forBusiness($business->id)->where('first_interaction_at', '>=', now()->subDays(30))->count(),
|
||||
];
|
||||
|
||||
// Build query based on filter
|
||||
$buyersQuery = BuyerEngagementScore::forBusiness($business->id);
|
||||
|
||||
match ($filter) {
|
||||
'high-value' => $buyersQuery->highValue(),
|
||||
'at-risk' => $buyersQuery->atRisk(),
|
||||
'new' => $buyersQuery->where('first_interaction_at', '>=', now()->subDays(30)),
|
||||
default => $buyersQuery,
|
||||
};
|
||||
|
||||
$buyers = $buyersQuery->orderByDesc('score')
|
||||
->with('buyerBusiness')
|
||||
->paginate(20);
|
||||
|
||||
// Engagement score distribution
|
||||
$scoreDistribution = BuyerEngagementScore::forBusiness($business->id)->select(
|
||||
DB::raw("CASE
|
||||
WHEN score >= 80 THEN 'Very High (80-100)'
|
||||
WHEN score >= 60 THEN 'High (60-79)'
|
||||
WHEN score >= 40 THEN 'Medium (40-59)'
|
||||
WHEN score >= 20 THEN 'Low (20-39)'
|
||||
ELSE 'Very Low (0-19)'
|
||||
END as score_range"),
|
||||
DB::raw('COUNT(*) as count')
|
||||
)
|
||||
->groupBy('score_range')
|
||||
->get();
|
||||
|
||||
// Tier distribution
|
||||
$tierDistribution = BuyerEngagementScore::forBusiness($business->id)->select('score_tier')
|
||||
->selectRaw('COUNT(*) as count')
|
||||
->groupBy('score_tier')
|
||||
->get();
|
||||
|
||||
// Recent high-intent signals
|
||||
$recentIntentSignals = IntentSignal::forBusiness($business->id)->highIntent()
|
||||
->where('detected_at', '>=', now()->subDays(7))
|
||||
->orderByDesc('detected_at')
|
||||
->with(['buyerBusiness', 'user'])
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
// Intent signal breakdown
|
||||
$signalBreakdown = IntentSignal::forBusiness($business->id)->where('detected_at', '>=', $startDate)
|
||||
->select('signal_type')
|
||||
->selectRaw('COUNT(*) as count')
|
||||
->selectRaw('AVG(signal_strength) as avg_strength')
|
||||
->groupBy('signal_type')
|
||||
->orderByDesc('count')
|
||||
->get();
|
||||
|
||||
return view('seller.analytics.buyers', compact(
|
||||
'business',
|
||||
'period',
|
||||
'filter',
|
||||
'metrics',
|
||||
'buyers',
|
||||
'scoreDistribution',
|
||||
'tierDistribution',
|
||||
'recentIntentSignals',
|
||||
'signalBreakdown'
|
||||
));
|
||||
}
|
||||
|
||||
public function show(Request $request, Business $buyer)
|
||||
{
|
||||
// TODO: Re-enable when permission system is implemented
|
||||
// if (! hasBusinessPermission('analytics.buyers')) {
|
||||
// abort(403, 'Unauthorized to view buyer intelligence');
|
||||
// }
|
||||
|
||||
$business = currentBusiness();
|
||||
$period = $request->input('period', '90'); // Default to 90 days for buyer detail
|
||||
$startDate = now()->subDays((int) $period);
|
||||
|
||||
// Get engagement score
|
||||
$engagementScore = BuyerEngagementScore::forBusiness($business->id)->where('buyer_business_id', $buyer->id)->first();
|
||||
|
||||
// Activity timeline
|
||||
$activityTimeline = ProductView::forBusiness($business->id)->where('buyer_business_id', $buyer->id)
|
||||
->where('viewed_at', '>=', $startDate)
|
||||
->select(
|
||||
DB::raw('DATE(viewed_at) as date'),
|
||||
DB::raw('COUNT(*) as product_views'),
|
||||
DB::raw('COUNT(DISTINCT product_id) as unique_products'),
|
||||
DB::raw('SUM(CASE WHEN added_to_cart = true THEN 1 ELSE 0 END) as cart_adds')
|
||||
)
|
||||
->groupBy('date')
|
||||
->orderBy('date')
|
||||
->get();
|
||||
|
||||
// Products viewed
|
||||
$productsViewed = ProductView::forBusiness($business->id)->where('buyer_business_id', $buyer->id)
|
||||
->where('viewed_at', '>=', $startDate)
|
||||
->select('product_id')
|
||||
->selectRaw('COUNT(*) as view_count')
|
||||
->selectRaw('MAX(viewed_at) as last_viewed')
|
||||
->selectRaw('AVG(time_on_page) as avg_time')
|
||||
->selectRaw('SUM(CASE WHEN added_to_cart = true THEN 1 ELSE 0 END) as cart_adds')
|
||||
->groupBy('product_id')
|
||||
->orderByDesc('view_count')
|
||||
->with('product')
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
// Intent signals
|
||||
$intentSignals = IntentSignal::forBusiness($business->id)->where('buyer_business_id', $buyer->id)
|
||||
->where('detected_at', '>=', $startDate)
|
||||
->orderByDesc('detected_at')
|
||||
->limit(50)
|
||||
->get();
|
||||
|
||||
// Email engagement
|
||||
$emailEngagement = DB::table('email_interactions')
|
||||
->join('users', 'email_interactions.recipient_user_id', '=', 'users.id')
|
||||
->join('business_user', 'users.id', '=', 'business_user.user_id')
|
||||
->where('email_interactions.business_id', $business->id)
|
||||
->where('business_user.business_id', $buyer->id)
|
||||
->where('email_interactions.sent_at', '>=', $startDate)
|
||||
->selectRaw('COUNT(*) as total_sent')
|
||||
->selectRaw('SUM(open_count) as total_opens')
|
||||
->selectRaw('SUM(click_count) as total_clicks')
|
||||
->selectRaw('AVG(engagement_score) as avg_engagement')
|
||||
->first();
|
||||
|
||||
// Order history
|
||||
$orderHistory = DB::table('orders')
|
||||
->where('seller_business_id', $business->id)
|
||||
->where('buyer_business_id', $buyer->id)
|
||||
->select(
|
||||
DB::raw('DATE(created_at) as date'),
|
||||
DB::raw('COUNT(*) as order_count'),
|
||||
DB::raw('SUM(total) as revenue')
|
||||
)
|
||||
->groupBy('date')
|
||||
->orderBy('date')
|
||||
->get();
|
||||
|
||||
$totalOrders = DB::table('orders')
|
||||
->where('seller_business_id', $business->id)
|
||||
->where('buyer_business_id', $buyer->id)
|
||||
->selectRaw('COUNT(*) as count')
|
||||
->selectRaw('SUM(total) as total_revenue')
|
||||
->selectRaw('AVG(total) as avg_order_value')
|
||||
->first();
|
||||
|
||||
return view('seller.analytics.buyer-detail', compact(
|
||||
'buyer',
|
||||
'period',
|
||||
'engagementScore',
|
||||
'activityTimeline',
|
||||
'productsViewed',
|
||||
'intentSignals',
|
||||
'emailEngagement',
|
||||
'orderHistory',
|
||||
'totalOrders'
|
||||
));
|
||||
}
|
||||
}
|
||||
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', [])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ class AuthenticatedSessionController extends Controller
|
||||
|
||||
$request->session()->regenerate();
|
||||
|
||||
// Redirect to main dashboard (LeafLink-style simple route)
|
||||
// Redirect to main dashboard
|
||||
return redirect(dashboard_url());
|
||||
}
|
||||
|
||||
|
||||
@@ -5,11 +5,17 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Business;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Services\PermissionService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected PermissionService $permissionService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Display users with access to the business.
|
||||
*/
|
||||
@@ -23,9 +29,10 @@ class UserController extends Controller
|
||||
->with('error', 'No business associated with your account.');
|
||||
}
|
||||
|
||||
// Load users with their pivot data (contact_type, is_primary, permissions)
|
||||
// Load users with their pivot data (contact_type, is_primary, permissions, role_template)
|
||||
$users = $business->users()
|
||||
->withPivot('contact_type', 'is_primary', 'permissions')
|
||||
->withPivot('contact_type', 'is_primary', 'permissions', 'role', 'role_template')
|
||||
->with('roles')
|
||||
->orderBy('is_primary', 'desc')
|
||||
->orderBy('first_name')
|
||||
->get();
|
||||
@@ -33,6 +40,8 @@ class UserController extends Controller
|
||||
return view('business.users.index', [
|
||||
'business' => $business,
|
||||
'users' => $users,
|
||||
'roleTemplates' => $this->permissionService->getRoleTemplates(),
|
||||
'permissionCategories' => $this->permissionService->getPermissionsByCategory(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
185
app/Http/Controllers/Business/UserPermissionsController.php
Normal file
185
app/Http/Controllers/Business/UserPermissionsController.php
Normal file
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Business;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Services\PermissionService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class UserPermissionsController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected PermissionService $permissionService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Update user permissions via AJAX
|
||||
*/
|
||||
public function update(Request $request, string $businessSlug, int $userId)
|
||||
{
|
||||
try {
|
||||
$business = currentBusiness();
|
||||
|
||||
if (! $business) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Business not found',
|
||||
], 404);
|
||||
}
|
||||
|
||||
// Only owners and admins can manage permissions
|
||||
if (auth()->user()->user_type !== 'admin' && $business->owner_user_id !== auth()->id()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'You do not have permission to manage user permissions',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$user = User::findOrFail($userId);
|
||||
|
||||
// Verify user belongs to this business
|
||||
if (! $user->businesses()->where('businesses.id', $business->id)->exists()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'User does not belong to this business',
|
||||
], 404);
|
||||
}
|
||||
|
||||
// Prevent owner from modifying their own permissions
|
||||
if ($user->id === $business->owner_user_id) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Cannot modify owner permissions',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'permissions' => 'array',
|
||||
'permissions.*' => 'string',
|
||||
'role_template' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$permissions = $validated['permissions'] ?? [];
|
||||
$roleTemplate = $validated['role_template'] ?? null;
|
||||
|
||||
// Set permissions using PermissionService
|
||||
$success = $this->permissionService->setPermissions(
|
||||
user: $user,
|
||||
permissions: $permissions,
|
||||
business: $business,
|
||||
roleTemplate: $roleTemplate,
|
||||
reason: 'Updated by '.auth()->user()->name.' via permissions modal'
|
||||
);
|
||||
|
||||
if ($success) {
|
||||
Log::info('User permissions updated', [
|
||||
'business_id' => $business->id,
|
||||
'target_user_id' => $user->id,
|
||||
'actor_user_id' => auth()->id(),
|
||||
'permissions_count' => count($permissions),
|
||||
'role_template' => $roleTemplate,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Permissions updated successfully',
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to update permissions',
|
||||
], 500);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error updating user permissions', [
|
||||
'error' => $e->getMessage(),
|
||||
'user_id' => $userId,
|
||||
'business_slug' => $businessSlug,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'An error occurred while updating permissions',
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a role template to a user
|
||||
*/
|
||||
public function applyTemplate(Request $request, string $businessSlug, int $userId)
|
||||
{
|
||||
try {
|
||||
$business = currentBusiness();
|
||||
|
||||
if (! $business) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Business not found',
|
||||
], 404);
|
||||
}
|
||||
|
||||
// Only owners and admins can manage permissions
|
||||
if (auth()->user()->user_type !== 'admin' && $business->owner_user_id !== auth()->id()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'You do not have permission to manage user permissions',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$user = User::findOrFail($userId);
|
||||
|
||||
// Verify user belongs to this business
|
||||
if (! $user->businesses()->where('businesses.id', $business->id)->exists()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'User does not belong to this business',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'template_key' => 'required|string',
|
||||
'merge' => 'boolean',
|
||||
]);
|
||||
|
||||
$templateKey = $validated['template_key'];
|
||||
$merge = $validated['merge'] ?? false;
|
||||
|
||||
// Apply role template
|
||||
$permissions = $this->permissionService->applyRoleTemplate(
|
||||
user: $user,
|
||||
templateKey: $templateKey,
|
||||
business: $business,
|
||||
merge: $merge
|
||||
);
|
||||
|
||||
if ($permissions === null) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Role template not found',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Role template applied successfully',
|
||||
'permissions' => $permissions,
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error applying role template', [
|
||||
'error' => $e->getMessage(),
|
||||
'user_id' => $userId,
|
||||
'business_slug' => $businessSlug,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'An error occurred while applying role template',
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
63
app/Http/Controllers/Buyer/BrandBrowseController.php
Normal file
63
app/Http/Controllers/Buyer/BrandBrowseController.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Buyer;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Brand;
|
||||
use App\Models\Business;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class BrandBrowseController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show brand menu for buyers to browse and order
|
||||
* This is the main product browsing interface for buyers
|
||||
*
|
||||
* @return \Illuminate\View\View
|
||||
*/
|
||||
public function browse(Request $request, string $businessSlug, string $brandHashid)
|
||||
{
|
||||
// Manually resolve business and brand (cross-tenant access allowed)
|
||||
// Buyers can browse ANY seller's brand menu
|
||||
$business = Business::where('slug', $businessSlug)->firstOrFail();
|
||||
$brand = Brand::where('hashid', $brandHashid)
|
||||
->where('business_id', $business->id)
|
||||
->where('is_active', true)
|
||||
->firstOrFail();
|
||||
|
||||
// Load brand with business relationship
|
||||
$brand->load('business');
|
||||
|
||||
// Get products organized by product line
|
||||
$products = $brand->products()
|
||||
->with(['strain', 'images', 'productLine'])
|
||||
->where('is_active', true)
|
||||
->orderBy('product_line_id')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Group products by product line
|
||||
$productsByLine = $products->groupBy(function ($product) {
|
||||
return $product->productLine ? $product->productLine->name : 'Other Products';
|
||||
});
|
||||
|
||||
// Get other brands from same business
|
||||
$otherBrands = $business
|
||||
->brands()
|
||||
->where('id', '!=', $brand->id)
|
||||
->where('is_active', true)
|
||||
->get();
|
||||
|
||||
// Mark this as buyer view
|
||||
$isSeller = false;
|
||||
|
||||
return view('seller.brands.preview', compact(
|
||||
'business',
|
||||
'brand',
|
||||
'products',
|
||||
'productsByLine',
|
||||
'otherBrands',
|
||||
'isSeller'
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -56,25 +56,27 @@ class DashboardController extends Controller
|
||||
$previousStart = now()->subDays(60);
|
||||
$previousEnd = now()->subDays(30);
|
||||
|
||||
// Get order IDs that have items matching our brands
|
||||
$currentOrderIds = \App\Models\OrderItem::whereIn('brand_name', $brandNames)
|
||||
->whereHas('order', fn ($q) => $q->whereBetween('created_at', [$currentStart, $currentEnd]))
|
||||
->pluck('order_id')
|
||||
->unique();
|
||||
// Get order IDs and revenue in single optimized queries using joins
|
||||
$currentStats = \App\Models\Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
|
||||
->whereIn('order_items.brand_name', $brandNames)
|
||||
->whereBetween('orders.created_at', [$currentStart, $currentEnd])
|
||||
->selectRaw('COUNT(DISTINCT orders.id) as order_count, SUM(orders.total) as revenue')
|
||||
->first();
|
||||
|
||||
$previousOrderIds = \App\Models\OrderItem::whereIn('brand_name', $brandNames)
|
||||
->whereHas('order', fn ($q) => $q->whereBetween('created_at', [$previousStart, $previousEnd]))
|
||||
->pluck('order_id')
|
||||
->unique();
|
||||
$previousStats = \App\Models\Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
|
||||
->whereIn('order_items.brand_name', $brandNames)
|
||||
->whereBetween('orders.created_at', [$previousStart, $previousEnd])
|
||||
->selectRaw('COUNT(DISTINCT orders.id) as order_count, SUM(orders.total) as revenue')
|
||||
->first();
|
||||
|
||||
// Revenue
|
||||
$currentRevenue = \App\Models\Order::whereIn('id', $currentOrderIds)->sum('total') / 100;
|
||||
$previousRevenue = \App\Models\Order::whereIn('id', $previousOrderIds)->sum('total') / 100;
|
||||
$currentRevenue = ($currentStats->revenue ?? 0) / 100;
|
||||
$previousRevenue = ($previousStats->revenue ?? 0) / 100;
|
||||
$revenueChange = $previousRevenue > 0 ? (($currentRevenue - $previousRevenue) / $previousRevenue) * 100 : 0;
|
||||
|
||||
// Orders count
|
||||
$currentOrders = $currentOrderIds->count();
|
||||
$previousOrders = $previousOrderIds->count();
|
||||
$currentOrders = $currentStats->order_count ?? 0;
|
||||
$previousOrders = $previousStats->order_count ?? 0;
|
||||
$ordersChange = $previousOrders > 0 ? (($currentOrders - $previousOrders) / $previousOrders) * 100 : 0;
|
||||
|
||||
// Products count (active products for selected brand(s))
|
||||
@@ -188,16 +190,11 @@ class DashboardController extends Controller
|
||||
$start = now()->sub($count, $unit)->startOfDay();
|
||||
$end = now()->endOfDay();
|
||||
|
||||
// Get all order IDs for the period
|
||||
$orderIds = \App\Models\OrderItem::whereIn('brand_name', $brandNames)
|
||||
->whereHas('order', fn ($q) => $q->whereBetween('created_at', [$start, $end]))
|
||||
->pluck('order_id')
|
||||
->unique();
|
||||
|
||||
// Get orders with dates
|
||||
$orders = \App\Models\Order::whereIn('id', $orderIds)
|
||||
->whereBetween('created_at', [$start, $end])
|
||||
->selectRaw('DATE(created_at) as date, SUM(total) as revenue')
|
||||
// Optimized query using join instead of subquery
|
||||
$orders = \App\Models\Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
|
||||
->whereIn('order_items.brand_name', $brandNames)
|
||||
->whereBetween('orders.created_at', [$start, $end])
|
||||
->selectRaw('DATE(orders.created_at) as date, SUM(orders.total) as revenue')
|
||||
->groupBy('date')
|
||||
->orderBy('date', 'asc')
|
||||
->get();
|
||||
|
||||
400
app/Http/Controllers/Seller/BatchController.php
Normal file
400
app/Http/Controllers/Seller/BatchController.php
Normal file
@@ -0,0 +1,400 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Batch;
|
||||
use App\Models\Business;
|
||||
use App\Models\Component;
|
||||
use App\Models\Product;
|
||||
use App\Services\QrCodeService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class BatchController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of batches for the business
|
||||
*/
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
// Build query for batches
|
||||
$query = Batch::where('business_id', $business->id)
|
||||
->with(['product.brand', 'coaFiles'])
|
||||
->orderBy('production_date', 'desc');
|
||||
|
||||
// Search filter
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('batch_number', 'LIKE', "%{$search}%")
|
||||
->orWhere('test_id', 'LIKE', "%{$search}%")
|
||||
->orWhere('lot_number', 'LIKE', "%{$search}%")
|
||||
->orWhereHas('product', function ($productQuery) use ($search) {
|
||||
$productQuery->where('name', 'LIKE', "%{$search}%");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
$batches = $query->paginate(20)->withQueryString();
|
||||
|
||||
// Separate active and inactive batches
|
||||
$activeBatches = $batches->filter(fn ($batch) => $batch->is_active);
|
||||
$inactiveBatches = $batches->filter(fn ($batch) => ! $batch->is_active);
|
||||
|
||||
return view('seller.batches.index', compact('business', 'batches', 'activeBatches', 'inactiveBatches'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for creating a new batch
|
||||
*/
|
||||
public function create(Request $request, Business $business)
|
||||
{
|
||||
// Get components owned by this business (for component batches)
|
||||
$components = Component::where('business_id', $business->id)
|
||||
->orderBy('name', 'asc')
|
||||
->get();
|
||||
|
||||
// Get existing component batches (for homogenized batch source selection)
|
||||
$componentBatches = Batch::where('business_id', $business->id)
|
||||
->where('batch_type', 'component')
|
||||
->where('is_active', true)
|
||||
->where('quantity_remaining', '>', 0)
|
||||
->with('component')
|
||||
->orderBy('batch_number', 'desc')
|
||||
->get();
|
||||
|
||||
return view('seller.batches.create', compact('business', 'components', 'componentBatches'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created batch - DEFINITIVE VERSION
|
||||
*
|
||||
* See BATCH_AND_LAB_SYSTEM.md for architecture details
|
||||
*/
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
// Batch type (component or homogenized)
|
||||
'batch_type' => 'required|in:component,homogenized',
|
||||
|
||||
// Component batch specific
|
||||
'component_id' => 'required_if:batch_type,component|nullable|exists:components,id',
|
||||
|
||||
// Basic batch info
|
||||
'batch_number' => 'required|string|max:100|unique:batches,batch_number',
|
||||
'internal_code' => 'nullable|string|max:100',
|
||||
'production_date' => 'required|date',
|
||||
'expiration_date' => 'nullable|date|after:production_date',
|
||||
|
||||
// Inventory tracking
|
||||
'quantity_total' => 'required|numeric|min:0',
|
||||
'quantity_remaining' => 'required|numeric|min:0',
|
||||
'quantity_unit' => 'required|in:lbs,g,kg,oz,units',
|
||||
|
||||
// Status flags
|
||||
'is_active' => 'sometimes|boolean',
|
||||
'is_tested' => 'sometimes|boolean',
|
||||
'is_quarantined' => 'sometimes|boolean',
|
||||
|
||||
// Notes
|
||||
'notes' => 'nullable|string',
|
||||
|
||||
// Source components (for homogenized batches)
|
||||
'source_components' => 'required_if:batch_type,homogenized|nullable|array',
|
||||
'source_components.*.batch_id' => 'required_with:source_components|exists:batches,id',
|
||||
'source_components.*.quantity_used' => 'required_with:source_components|numeric|min:0',
|
||||
'source_components.*.unit' => 'required_with:source_components|in:lbs,g,kg,oz,units',
|
||||
]);
|
||||
|
||||
// Verify component belongs to this business (if component batch)
|
||||
if ($validated['batch_type'] === 'component' && $validated['component_id']) {
|
||||
Component::where('business_id', $business->id)
|
||||
->where('id', $validated['component_id'])
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
// Verify source batches belong to this business (if homogenized)
|
||||
if ($validated['batch_type'] === 'homogenized' && ! empty($validated['source_components'])) {
|
||||
$sourceBatchIds = collect($validated['source_components'])->pluck('batch_id');
|
||||
$validCount = Batch::where('business_id', $business->id)
|
||||
->whereIn('id', $sourceBatchIds)
|
||||
->count();
|
||||
|
||||
if ($validCount !== $sourceBatchIds->count()) {
|
||||
return back()->withErrors(['source_components' => 'Some source batches do not belong to this business']);
|
||||
}
|
||||
}
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
// Set business_id and handle checkboxes/hidden fields
|
||||
$validated['business_id'] = $business->id;
|
||||
$validated['is_active'] = $request->has('is_active');
|
||||
// is_tested and is_quarantined come from hidden fields (always tested with COA, never quarantined initially)
|
||||
$validated['is_tested'] = (bool) $request->input('is_tested', true);
|
||||
$validated['is_quarantined'] = (bool) $request->input('is_quarantined', false);
|
||||
|
||||
// Null out component_id for homogenized batches
|
||||
if ($validated['batch_type'] === 'homogenized') {
|
||||
$validated['component_id'] = null;
|
||||
}
|
||||
|
||||
// Create batch
|
||||
$batch = Batch::create($validated);
|
||||
|
||||
// Handle source components for homogenized batches
|
||||
if ($validated['batch_type'] === 'homogenized' && ! empty($validated['source_components'])) {
|
||||
foreach ($validated['source_components'] as $sourceComponent) {
|
||||
// Attach source batch with quantity used
|
||||
$batch->sourceComponents()->attach($sourceComponent['batch_id'], [
|
||||
'quantity_used' => $sourceComponent['quantity_used'],
|
||||
'unit' => $sourceComponent['unit'],
|
||||
]);
|
||||
|
||||
// Optionally: Reduce quantity_remaining on source batch
|
||||
// $sourceBatch = Batch::find($sourceComponent['batch_id']);
|
||||
// $sourceBatch->consume($sourceComponent['quantity_used']);
|
||||
}
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.batches.index', $business->slug)
|
||||
->with('success', "Batch {$batch->batch_number} created successfully. Next: Link to SKU variants and upload COA.");
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
|
||||
return back()
|
||||
->withInput()
|
||||
->withErrors(['error' => 'Failed to create batch: '.$e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for editing the specified batch
|
||||
*/
|
||||
public function edit(Request $request, Business $business, Batch $batch)
|
||||
{
|
||||
// Verify batch belongs to this business
|
||||
if ($batch->business_id !== $business->id) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
// Get products owned by this business
|
||||
$products = Product::whereHas('brand', function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
})->orderBy('name', 'asc')->get();
|
||||
|
||||
$batch->load(['coaFiles', 'product.brand']);
|
||||
|
||||
return view('seller.batches.edit', compact('business', 'batch', 'products'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified batch
|
||||
*/
|
||||
public function update(Request $request, Business $business, Batch $batch)
|
||||
{
|
||||
// Verify batch belongs to this business
|
||||
if ($batch->business_id !== $business->id) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
// Determine max value based on unit (% vs mg/g, mg/ml, mg/unit)
|
||||
$maxValue = $request->cannabinoid_unit === '%' ? 100 : 1000;
|
||||
|
||||
$validated = $request->validate([
|
||||
'product_id' => 'required|exists:products,id',
|
||||
'cannabinoid_unit' => 'required|string|in:%,MG/ML,MG/G,MG/UNIT',
|
||||
'batch_number' => 'nullable|string|max:100|unique:batches,batch_number,'.$batch->id,
|
||||
'production_date' => 'nullable|date',
|
||||
'test_date' => 'nullable|date',
|
||||
'test_id' => 'nullable|string|max:100',
|
||||
'lot_number' => 'nullable|string|max:100',
|
||||
'lab_name' => 'nullable|string|max:255',
|
||||
'thc_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'thca_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'cbd_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'cbda_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'cbg_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'cbn_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'delta_9_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'total_terps_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'notes' => 'nullable|string',
|
||||
'coa_files.*' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240', // 10MB max per file
|
||||
]);
|
||||
|
||||
// Verify product belongs to this business
|
||||
$product = Product::whereHas('brand', function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
})->findOrFail($validated['product_id']);
|
||||
|
||||
// Update batch (calculations happen in model boot method)
|
||||
$batch->update($validated);
|
||||
|
||||
// Handle new COA file uploads
|
||||
if ($request->hasFile('coa_files')) {
|
||||
$existingFilesCount = $batch->coaFiles()->count();
|
||||
foreach ($request->file('coa_files') as $index => $file) {
|
||||
$storagePath = "businesses/{$business->uuid}/batches/{$batch->id}/coas";
|
||||
$fileName = uniqid().'.'.$file->getClientOriginalExtension();
|
||||
$filePath = $file->storeAs($storagePath, $fileName, 'public');
|
||||
|
||||
$batch->coaFiles()->create([
|
||||
'file_name' => $file->getClientOriginalName(),
|
||||
'file_path' => $filePath,
|
||||
'file_type' => $file->getClientOriginalExtension(),
|
||||
'file_size' => $file->getSize(),
|
||||
'is_primary' => $existingFilesCount === 0 && $index === 0,
|
||||
'display_order' => $existingFilesCount + $index,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.batches.index', $business->slug)
|
||||
->with('success', 'Batch updated successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified batch
|
||||
*/
|
||||
public function destroy(Request $request, Business $business, Batch $batch)
|
||||
{
|
||||
// Verify batch belongs to this business
|
||||
if ($batch->business_id !== $business->id) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
// Delete associated COA files from storage
|
||||
foreach ($batch->coaFiles as $coaFile) {
|
||||
if (Storage::disk('public')->exists($coaFile->file_path)) {
|
||||
Storage::disk('public')->delete($coaFile->file_path);
|
||||
}
|
||||
}
|
||||
|
||||
$batch->delete();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.batches.index', $business->slug)
|
||||
->with('success', 'Batch deleted successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate QR code for a batch
|
||||
*/
|
||||
public function generateQrCode(Request $request, Business $business, Batch $batch)
|
||||
{
|
||||
// Verify batch belongs to this business
|
||||
if ($batch->business_id !== $business->id) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
$qrService = app(QrCodeService::class);
|
||||
$result = $qrService->generateWithLogo($batch);
|
||||
|
||||
// Refresh batch to get updated qr_code_path
|
||||
$batch->refresh();
|
||||
|
||||
return response()->json([
|
||||
'success' => $result['success'],
|
||||
'message' => $result['message'],
|
||||
'qr_code_url' => $batch->qr_code_path ? Storage::url($batch->qr_code_path) : null,
|
||||
'download_url' => $batch->qr_code_path ? route('seller.business.batches.qr-code.download', [$business->slug, $batch->id]) : null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download QR code for a batch
|
||||
*/
|
||||
public function downloadQrCode(Request $request, Business $business, Batch $batch)
|
||||
{
|
||||
// Verify batch belongs to this business
|
||||
if ($batch->business_id !== $business->id) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
$qrService = app(QrCodeService::class);
|
||||
$download = $qrService->download($batch);
|
||||
|
||||
if (! $download) {
|
||||
return back()->with('error', 'QR code not found');
|
||||
}
|
||||
|
||||
return $download;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate QR code for a batch
|
||||
*/
|
||||
public function regenerateQrCode(Request $request, Business $business, Batch $batch)
|
||||
{
|
||||
// Verify batch belongs to this business
|
||||
if ($batch->business_id !== $business->id) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
$qrService = app(QrCodeService::class);
|
||||
$result = $qrService->regenerate($batch);
|
||||
|
||||
// Refresh batch to get updated qr_code_path
|
||||
$batch->refresh();
|
||||
|
||||
return response()->json([
|
||||
'success' => $result['success'],
|
||||
'message' => $result['message'],
|
||||
'qr_code_url' => $batch->qr_code_path ? Storage::url($batch->qr_code_path) : null,
|
||||
'download_url' => $batch->qr_code_path ? route('seller.business.batches.qr-code.download', [$business->slug, $batch->id]) : null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete QR code for a batch
|
||||
*/
|
||||
public function deleteQrCode(Request $request, Business $business, Batch $batch)
|
||||
{
|
||||
// Verify batch belongs to this business
|
||||
if ($batch->business_id !== $business->id) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
$qrService = app(QrCodeService::class);
|
||||
$result = $qrService->delete($batch);
|
||||
|
||||
return response()->json([
|
||||
'success' => $result['success'],
|
||||
'message' => $result['message'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk generate QR codes for multiple batches
|
||||
*/
|
||||
public function bulkGenerateQrCodes(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'batch_ids' => 'required|array',
|
||||
'batch_ids.*' => 'exists:batches,id',
|
||||
]);
|
||||
|
||||
// Verify all batches belong to this business
|
||||
$batches = Batch::whereIn('id', $validated['batch_ids'])
|
||||
->where('business_id', $business->id)
|
||||
->get();
|
||||
|
||||
if ($batches->count() !== count($validated['batch_ids'])) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Some batches do not belong to this business',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$qrService = app(QrCodeService::class);
|
||||
$result = $qrService->bulkGenerate($validated['batch_ids']);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
}
|
||||
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');
|
||||
}
|
||||
}
|
||||
53
app/Http/Controllers/Seller/MarketplacePreviewController.php
Normal file
53
app/Http/Controllers/Seller/MarketplacePreviewController.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Brand;
|
||||
use App\Models\Product;
|
||||
|
||||
class MarketplacePreviewController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show brand marketplace preview for sellers
|
||||
* This allows sellers to preview how their brand appears on the marketplace
|
||||
*/
|
||||
public function showBrand($brandSlug)
|
||||
{
|
||||
// Find brand by slug
|
||||
$brand = Brand::query()
|
||||
->where('slug', $brandSlug)
|
||||
->active()
|
||||
->firstOrFail();
|
||||
|
||||
// Verify the authenticated seller owns this brand
|
||||
$userBusiness = auth()->user()->businesses->first();
|
||||
|
||||
if (! $userBusiness || $brand->business_id !== $userBusiness->id) {
|
||||
abort(403, 'You do not have permission to preview this brand.');
|
||||
}
|
||||
|
||||
// Get featured products from this brand
|
||||
$featuredProducts = Product::query()
|
||||
->with(['strain'])
|
||||
->where('brand_id', $brand->id)
|
||||
->featured()
|
||||
->inStock()
|
||||
->limit(3)
|
||||
->get();
|
||||
|
||||
// Get all products from this brand
|
||||
$products = Product::query()
|
||||
->with(['strain'])
|
||||
->where('brand_id', $brand->id)
|
||||
->active()
|
||||
->orderBy('is_featured', 'desc')
|
||||
->orderBy('name')
|
||||
->paginate(20);
|
||||
|
||||
$business = $userBusiness;
|
||||
$isSellerPreview = true; // Flag to indicate this is seller preview mode
|
||||
|
||||
return view('seller.marketplace.brand-preview', compact('brand', 'featuredProducts', 'products', 'business', 'isSellerPreview'));
|
||||
}
|
||||
}
|
||||
@@ -191,6 +191,9 @@ class ProductController extends Controller
|
||||
'discontinued' => 'Discontinued',
|
||||
];
|
||||
|
||||
// Get audit history for this product
|
||||
$audits = $product->audits()->with('user')->latest()->paginate(10);
|
||||
|
||||
return view('seller.products.edit', compact(
|
||||
'business',
|
||||
'product',
|
||||
@@ -200,7 +203,8 @@ class ProductController extends Controller
|
||||
'units',
|
||||
'productLines',
|
||||
'productTypes',
|
||||
'statusOptions'
|
||||
'statusOptions',
|
||||
'audits'
|
||||
));
|
||||
}
|
||||
|
||||
@@ -242,6 +246,9 @@ class ProductController extends Controller
|
||||
'discontinued' => 'Discontinued',
|
||||
];
|
||||
|
||||
// Get audit history for this product
|
||||
$audits = $product->audits()->with('user')->latest()->paginate(10);
|
||||
|
||||
return view('seller.products.edit1', compact(
|
||||
'business',
|
||||
'product',
|
||||
@@ -251,7 +258,8 @@ class ProductController extends Controller
|
||||
'units',
|
||||
'productLines',
|
||||
'productTypes',
|
||||
'statusOptions'
|
||||
'statusOptions',
|
||||
'audits'
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Product;
|
||||
use App\Models\ProductImage;
|
||||
use App\Services\ImageBackgroundRemovalService;
|
||||
use App\Traits\FileStorageHelper;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
@@ -40,6 +41,16 @@ class ProductImageController extends Controller
|
||||
// Store the image using trait method
|
||||
$path = $this->storeFile($request->file('image'), 'products');
|
||||
|
||||
// Remove background from the uploaded image
|
||||
$backgroundRemovalService = new ImageBackgroundRemovalService;
|
||||
$fullPath = storage_path('app/public/'.$path);
|
||||
$processedPath = $backgroundRemovalService->removeWhiteBackground($fullPath);
|
||||
|
||||
// Update path if it changed (e.g., converted from JPG to PNG)
|
||||
if ($processedPath && $processedPath !== $fullPath) {
|
||||
$path = str_replace(storage_path('app/public/'), '', $processedPath);
|
||||
}
|
||||
|
||||
// Determine if this should be the primary image (first one)
|
||||
$isPrimary = $product->images()->count() === 0;
|
||||
|
||||
|
||||
@@ -30,13 +30,51 @@ class SettingsController extends Controller
|
||||
'license_number' => 'nullable|string|max:255',
|
||||
'license_type' => 'nullable|string',
|
||||
'physical_address' => 'nullable|string|max:255',
|
||||
'physical_suite' => 'nullable|string|max:50',
|
||||
'physical_city' => 'nullable|string|max:100',
|
||||
'physical_state' => 'nullable|string|max:2',
|
||||
'physical_zipcode' => 'nullable|string|max:10',
|
||||
'business_phone' => 'nullable|string|max:20',
|
||||
'business_email' => 'nullable|email|max:255',
|
||||
'logo' => 'nullable|image|max:2048', // 2MB max
|
||||
'banner' => 'nullable|image|max:4096', // 4MB max
|
||||
'remove_logo' => 'nullable|boolean',
|
||||
'remove_banner' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
// Handle logo removal
|
||||
if ($request->has('remove_logo') && $business->logo_path) {
|
||||
\Storage::disk('public')->delete($business->logo_path);
|
||||
$validated['logo_path'] = null;
|
||||
}
|
||||
|
||||
// Handle logo upload
|
||||
if ($request->hasFile('logo')) {
|
||||
// Delete old logo if exists
|
||||
if ($business->logo_path) {
|
||||
\Storage::disk('public')->delete($business->logo_path);
|
||||
}
|
||||
$validated['logo_path'] = $request->file('logo')->store('businesses/logos', 'public');
|
||||
}
|
||||
|
||||
// Handle banner removal
|
||||
if ($request->has('remove_banner') && $business->banner_path) {
|
||||
\Storage::disk('public')->delete($business->banner_path);
|
||||
$validated['banner_path'] = null;
|
||||
}
|
||||
|
||||
// Handle banner upload
|
||||
if ($request->hasFile('banner')) {
|
||||
// Delete old banner if exists
|
||||
if ($business->banner_path) {
|
||||
\Storage::disk('public')->delete($business->banner_path);
|
||||
}
|
||||
$validated['banner_path'] = $request->file('banner')->store('businesses/banners', 'public');
|
||||
}
|
||||
|
||||
// Remove file inputs from validated data (already handled above)
|
||||
unset($validated['logo'], $validated['banner'], $validated['remove_logo'], $validated['remove_banner']);
|
||||
|
||||
$business->update($validated);
|
||||
|
||||
return redirect()
|
||||
@@ -47,9 +85,152 @@ class SettingsController extends Controller
|
||||
/**
|
||||
* Display the users management settings page.
|
||||
*/
|
||||
public function users(Business $business)
|
||||
public function users(Business $business, Request $request, \App\Services\PermissionService $permissionService)
|
||||
{
|
||||
return view('seller.settings.users', compact('business'));
|
||||
$query = $business->users();
|
||||
|
||||
// Search
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by account type (role)
|
||||
if ($request->filled('account_type')) {
|
||||
$query->whereHas('roles', function ($q) use ($request) {
|
||||
$q->where('name', $request->account_type);
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by last login date range
|
||||
if ($request->filled('last_login_start')) {
|
||||
$query->where('last_login_at', '>=', $request->last_login_start);
|
||||
}
|
||||
if ($request->filled('last_login_end')) {
|
||||
$query->where('last_login_at', '<=', $request->last_login_end.' 23:59:59');
|
||||
}
|
||||
|
||||
$users = $query
|
||||
->withPivot('contact_type', 'is_primary', 'permissions', 'role', 'role_template')
|
||||
->with('roles')
|
||||
->orderBy('last_name')
|
||||
->orderBy('first_name')
|
||||
->paginate(15);
|
||||
|
||||
$roleTemplates = $permissionService->getRoleTemplates();
|
||||
$permissionCategories = $permissionService->getPermissionsByCategory();
|
||||
|
||||
return view('seller.settings.users', compact('business', 'users', 'roleTemplates', 'permissionCategories'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created user invitation.
|
||||
*/
|
||||
public function inviteUser(Business $business, Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'first_name' => 'required|string|max:255',
|
||||
'last_name' => 'required|string|max:255',
|
||||
'email' => 'required|email|unique:users,email',
|
||||
'phone' => 'nullable|string|max:20',
|
||||
'position' => 'nullable|string|max:255',
|
||||
'role' => 'required|string|in:company-owner,company-manager,company-user,company-sales,company-accounting,company-manufacturing,company-processing',
|
||||
'is_point_of_contact' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
// Combine first and last name
|
||||
$fullName = trim($validated['first_name'].' '.$validated['last_name']);
|
||||
|
||||
// Create user and associate with business
|
||||
$user = \App\Models\User::create([
|
||||
'name' => $fullName,
|
||||
'email' => $validated['email'],
|
||||
'phone' => $validated['phone'],
|
||||
'password' => bcrypt(str()->random(32)), // Temporary password
|
||||
]);
|
||||
|
||||
// Assign role
|
||||
$user->assignRole($validated['role']);
|
||||
|
||||
// Associate with business with additional pivot data
|
||||
$business->users()->attach($user->id, [
|
||||
'role' => $validated['role'],
|
||||
'is_primary' => false,
|
||||
'contact_type' => $request->has('is_point_of_contact') ? 'primary' : null,
|
||||
]);
|
||||
|
||||
// TODO: Send invitation email with password reset link
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.settings.users', $business->slug)
|
||||
->with('success', 'User invited successfully!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user information and permissions.
|
||||
*/
|
||||
public function updateUser(Business $business, \App\Models\User $user, Request $request)
|
||||
{
|
||||
// Check if user belongs to this business
|
||||
if (! $business->users()->where('users.id', $user->id)->exists()) {
|
||||
abort(403, 'User does not belong to this business');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'first_name' => 'required|string|max:255',
|
||||
'last_name' => 'required|string|max:255',
|
||||
'email' => 'required|email|unique:users,email,'.$user->id,
|
||||
'phone' => 'nullable|string|max:20',
|
||||
'position' => 'nullable|string|max:255',
|
||||
'role' => 'required|string|in:company-owner,company-manager,company-user,company-sales,company-accounting,company-manufacturing,company-processing',
|
||||
'is_point_of_contact' => 'nullable|boolean',
|
||||
'permissions' => 'nullable|array',
|
||||
]);
|
||||
|
||||
// Combine first and last name
|
||||
$fullName = trim($validated['first_name'].' '.$validated['last_name']);
|
||||
|
||||
// Update user
|
||||
$user->update([
|
||||
'name' => $fullName,
|
||||
'email' => $validated['email'],
|
||||
'phone' => $validated['phone'] ?? null,
|
||||
]);
|
||||
|
||||
// Update role
|
||||
$user->syncRoles([$validated['role']]);
|
||||
|
||||
// Update business_user pivot data
|
||||
$business->users()->updateExistingPivot($user->id, [
|
||||
'role' => $validated['role'],
|
||||
'contact_type' => $request->has('is_point_of_contact') ? 'primary' : null,
|
||||
'permissions' => $validated['permissions'] ?? null,
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.settings.users', $business->slug)
|
||||
->with('success', 'User updated successfully!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove user from business.
|
||||
*/
|
||||
public function removeUser(Business $business, \App\Models\User $user)
|
||||
{
|
||||
// Check if user belongs to this business
|
||||
if (! $business->users()->where('users.id', $user->id)->exists()) {
|
||||
abort(403, 'User does not belong to this business');
|
||||
}
|
||||
|
||||
// Detach user from business
|
||||
$business->users()->detach($user->id);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.settings.users', $business->slug)
|
||||
->with('success', 'User removed successfully!');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,12 +241,52 @@ class SettingsController extends Controller
|
||||
return view('seller.settings.orders', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the order settings.
|
||||
*/
|
||||
public function updateOrders(Business $business, Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'separate_orders_by_brand' => 'nullable|boolean',
|
||||
'auto_increment_order_ids' => 'nullable|boolean',
|
||||
'show_mark_as_paid' => 'nullable|boolean',
|
||||
'display_crm_license_on_orders' => 'nullable|boolean',
|
||||
'order_minimum' => 'nullable|numeric|min:0',
|
||||
'default_shipping_charge' => 'nullable|numeric|min:0',
|
||||
'free_shipping_minimum' => 'nullable|numeric|min:0',
|
||||
'order_disclaimer' => 'nullable|string|max:2000',
|
||||
'order_invoice_footer' => 'nullable|string|max:1000',
|
||||
'prevent_order_editing' => 'required|in:never,after_approval,after_fulfillment,always',
|
||||
'az_require_patient_count' => 'nullable|boolean',
|
||||
'az_require_allotment_verification' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
// Convert checkbox values (null means unchecked)
|
||||
$validated['separate_orders_by_brand'] = $request->has('separate_orders_by_brand');
|
||||
$validated['auto_increment_order_ids'] = $request->has('auto_increment_order_ids');
|
||||
$validated['show_mark_as_paid'] = $request->has('show_mark_as_paid');
|
||||
$validated['display_crm_license_on_orders'] = $request->has('display_crm_license_on_orders');
|
||||
$validated['az_require_patient_count'] = $request->has('az_require_patient_count');
|
||||
$validated['az_require_allotment_verification'] = $request->has('az_require_allotment_verification');
|
||||
|
||||
$business->update($validated);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.settings.orders', $business->slug)
|
||||
->with('success', 'Order settings updated successfully!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the brands management page.
|
||||
*/
|
||||
public function brands(Business $business)
|
||||
{
|
||||
return view('seller.settings.brands', compact('business'));
|
||||
$brands = $business->brands()
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('seller.settings.brands', compact('business', 'brands'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,6 +305,26 @@ class SettingsController extends Controller
|
||||
return view('seller.settings.invoices', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the invoice settings.
|
||||
*/
|
||||
public function updateInvoices(Business $business, Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'invoice_payable_company_name' => 'nullable|string|max:255',
|
||||
'invoice_payable_address' => 'nullable|string|max:255',
|
||||
'invoice_payable_city' => 'nullable|string|max:100',
|
||||
'invoice_payable_state' => 'nullable|string|max:2',
|
||||
'invoice_payable_zipcode' => 'nullable|string|max:10',
|
||||
]);
|
||||
|
||||
$business->update($validated);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.settings.invoices', $business->slug)
|
||||
->with('success', 'Invoice settings updated successfully!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the manage licenses page.
|
||||
*/
|
||||
@@ -108,6 +349,65 @@ class SettingsController extends Controller
|
||||
return view('seller.settings.notifications', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the notification settings.
|
||||
*
|
||||
* EMAIL NOTIFICATION RULES DOCUMENTATION:
|
||||
*
|
||||
* 1. NEW ORDER EMAIL NOTIFICATIONS (new_order_email_notifications)
|
||||
* Base: Email these addresses when a new order is placed
|
||||
* - If 'new_order_only_when_no_sales_rep' checked: ONLY send if buyer has NO sales rep assigned
|
||||
* - If 'new_order_do_not_send_to_admins' checked: Do NOT send to company admins (only to these addresses)
|
||||
*
|
||||
* 2. ORDER ACCEPTED EMAIL NOTIFICATIONS (order_accepted_email_notifications)
|
||||
* Base: Email these addresses when an order is accepted
|
||||
* - If 'enable_shipped_emails_for_sales_reps' checked: Sales reps assigned to customer get email when order marked Shipped
|
||||
*
|
||||
* 3. PLATFORM INQUIRY EMAIL NOTIFICATIONS (platform_inquiry_email_notifications)
|
||||
* Base: Email these addresses for inquiries
|
||||
* - Sales reps associated with customer ALWAYS receive email
|
||||
* - If field is blank AND no sales reps exist: company admins receive notifications
|
||||
*
|
||||
* 4. MANUAL ORDER EMAIL NOTIFICATIONS
|
||||
* - If 'enable_manual_order_email_notifications' checked: Send same emails for manual orders as buyer-created orders
|
||||
* - If 'enable_manual_order_email_notifications' unchecked: Only send for buyer-created orders
|
||||
* - If 'manual_order_emails_internal_only' checked: Send manual order emails to internal recipients only (not buyers)
|
||||
*
|
||||
* 5. LOW INVENTORY EMAIL NOTIFICATIONS (low_inventory_email_notifications)
|
||||
* Base: Email these addresses when inventory is low
|
||||
*
|
||||
* 6. CERTIFIED SELLER STATUS EMAIL NOTIFICATIONS (certified_seller_status_email_notifications)
|
||||
* Base: Email these addresses when seller status changes
|
||||
*/
|
||||
public function updateNotifications(Business $business, Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'new_order_email_notifications' => ['nullable', 'string', new \App\Rules\CommaSeparatedEmails],
|
||||
'new_order_only_when_no_sales_rep' => 'nullable|boolean',
|
||||
'new_order_do_not_send_to_admins' => 'nullable|boolean',
|
||||
'order_accepted_email_notifications' => ['nullable', 'string', new \App\Rules\CommaSeparatedEmails],
|
||||
'enable_shipped_emails_for_sales_reps' => 'nullable|boolean',
|
||||
'platform_inquiry_email_notifications' => ['nullable', 'string', new \App\Rules\CommaSeparatedEmails],
|
||||
'enable_manual_order_email_notifications' => 'nullable|boolean',
|
||||
'manual_order_emails_internal_only' => 'nullable|boolean',
|
||||
'low_inventory_email_notifications' => ['nullable', 'string', new \App\Rules\CommaSeparatedEmails],
|
||||
'certified_seller_status_email_notifications' => ['nullable', 'string', new \App\Rules\CommaSeparatedEmails],
|
||||
]);
|
||||
|
||||
// Convert checkbox values (null means unchecked)
|
||||
$validated['new_order_only_when_no_sales_rep'] = $request->has('new_order_only_when_no_sales_rep');
|
||||
$validated['new_order_do_not_send_to_admins'] = $request->has('new_order_do_not_send_to_admins');
|
||||
$validated['enable_shipped_emails_for_sales_reps'] = $request->has('enable_shipped_emails_for_sales_reps');
|
||||
$validated['enable_manual_order_email_notifications'] = $request->has('enable_manual_order_email_notifications');
|
||||
$validated['manual_order_emails_internal_only'] = $request->has('manual_order_emails_internal_only');
|
||||
|
||||
$business->update($validated);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.settings.notifications', $business->slug)
|
||||
->with('success', 'Notification settings updated successfully!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the report settings page.
|
||||
*/
|
||||
|
||||
223
app/Http/Controllers/Seller/WashReportController.php
Normal file
223
app/Http/Controllers/Seller/WashReportController.php
Normal file
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Batch;
|
||||
use App\Models\Business;
|
||||
use App\Models\Conversion;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class WashReportController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of wash reports
|
||||
*/
|
||||
public function index(Business $business)
|
||||
{
|
||||
$conversions = Conversion::where('business_id', $business->id)
|
||||
->where('conversion_type', 'hash_wash')
|
||||
->with(['operator', 'inputBatches', 'batchCreated'])
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate(20);
|
||||
|
||||
return view('seller.wash-reports.index', compact('business', 'conversions'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show Stage 1 form (wash parameters)
|
||||
*/
|
||||
public function createStage1(Business $business)
|
||||
{
|
||||
// Get available Fresh Frozen input material batches for this business
|
||||
$inputBatches = Batch::where('business_id', $business->id)
|
||||
->where('quantity_remaining', '>', 0)
|
||||
->where('is_tested', true)
|
||||
->where('is_quarantined', false)
|
||||
->orderBy('batch_number')
|
||||
->get();
|
||||
|
||||
return view('seller.wash-reports.stage1', compact('business', 'inputBatches'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store Stage 1 data and redirect to Stage 2
|
||||
*/
|
||||
public function storeStage1(Business $business, Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'wash_date' => 'required|date',
|
||||
'input_batch_id' => 'required|exists:batches,id',
|
||||
'starting_weight' => 'required|numeric|min:0',
|
||||
'soak_time_minutes' => 'required|integer|min:0',
|
||||
'room_temperature_f' => 'required|numeric',
|
||||
'vessel_temperature_f' => 'required|numeric',
|
||||
'strain' => 'required|string|max:255',
|
||||
'wash_cycles' => 'required|array|min:1',
|
||||
'wash_cycles.*.cycle' => 'required|integer|min:1',
|
||||
'wash_cycles.*.forward_speed' => 'required|integer|min:1|max:10',
|
||||
'wash_cycles.*.reverse_speed' => 'required|integer|min:1|max:10',
|
||||
'wash_cycles.*.pause' => 'required|integer|min:0',
|
||||
'wash_cycles.*.run_time' => 'required|integer|min:1',
|
||||
'notes' => 'nullable|string',
|
||||
]);
|
||||
|
||||
// Verify batch belongs to business
|
||||
$inputBatch = Batch::where('business_id', $business->id)
|
||||
->where('id', $validated['input_batch_id'])
|
||||
->firstOrFail();
|
||||
|
||||
// Verify sufficient quantity available
|
||||
if ($inputBatch->quantity_remaining < $validated['starting_weight']) {
|
||||
return back()->withErrors([
|
||||
'starting_weight' => 'Insufficient quantity available. Only '.$inputBatch->quantity_remaining.'g available.',
|
||||
])->withInput();
|
||||
}
|
||||
|
||||
// Create conversion with Stage 1 data
|
||||
$conversion = Conversion::create([
|
||||
'business_id' => $business->id,
|
||||
'conversion_type' => 'hash_wash',
|
||||
'status' => 'in_progress',
|
||||
'internal_name' => $validated['strain'].' Hash Wash #'.now()->format('Ymd-His'),
|
||||
'started_at' => $validated['wash_date'],
|
||||
'operator_user_id' => auth()->id(),
|
||||
'metadata' => [
|
||||
'stage_1' => [
|
||||
'wash_date' => $validated['wash_date'],
|
||||
'cultivator' => $inputBatch->cultivator ?? 'Unknown',
|
||||
'starting_weight' => (float) $validated['starting_weight'],
|
||||
'soak_time_minutes' => (int) $validated['soak_time_minutes'],
|
||||
'room_temperature_f' => (float) $validated['room_temperature_f'],
|
||||
'vessel_temperature_f' => (float) $validated['vessel_temperature_f'],
|
||||
'strain' => $validated['strain'],
|
||||
'wash_cycles' => $validated['wash_cycles'],
|
||||
],
|
||||
],
|
||||
'notes' => $validated['notes'],
|
||||
]);
|
||||
|
||||
// Link input batch to conversion
|
||||
$conversion->inputBatches()->attach($inputBatch->id, [
|
||||
'role' => 'input',
|
||||
'quantity_used' => $validated['starting_weight'],
|
||||
'unit' => 'g',
|
||||
]);
|
||||
|
||||
// Redirect to Stage 2
|
||||
return redirect()->route('seller.business.wash-reports.stage2', [
|
||||
'business' => $business->slug,
|
||||
'conversion' => $conversion->id,
|
||||
])->with('success', 'Stage 1 completed. Now enter yield details.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show Stage 2 form (yield tracking)
|
||||
*/
|
||||
public function createStage2(Business $business, Conversion $conversion)
|
||||
{
|
||||
// Verify conversion belongs to business
|
||||
if ($conversion->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
// Verify Stage 1 is complete
|
||||
if (! $conversion->getStage1Data()) {
|
||||
return redirect()->route('seller.business.wash-reports.stage1', $business->slug)
|
||||
->withErrors(['error' => 'Please complete Stage 1 first.']);
|
||||
}
|
||||
|
||||
$stage1Data = $conversion->getStage1Data();
|
||||
|
||||
return view('seller.wash-reports.stage2', compact('business', 'conversion', 'stage1Data'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store Stage 2 data and complete conversion
|
||||
*/
|
||||
public function storeStage2(Business $business, Conversion $conversion, Request $request)
|
||||
{
|
||||
// Verify conversion belongs to business
|
||||
if ($conversion->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'fresh_press_120u' => 'required|numeric|min:0',
|
||||
'cold_cure_90u' => 'required|numeric|min:0',
|
||||
'rosin_45u' => 'required|numeric|min:0',
|
||||
'green_blonde_160u' => 'required|numeric|min:0',
|
||||
'green_blonde_25u' => 'required|numeric|min:0',
|
||||
]);
|
||||
|
||||
$stage1Data = $conversion->getStage1Data();
|
||||
$startingWeight = $stage1Data['starting_weight'];
|
||||
|
||||
// Calculate individual percentages
|
||||
$freshPressPercentage = $startingWeight > 0 ? round(($validated['fresh_press_120u'] / $startingWeight) * 100, 2) : 0;
|
||||
$coldCurePercentage = $startingWeight > 0 ? round(($validated['cold_cure_90u'] / $startingWeight) * 100, 2) : 0;
|
||||
$rosinPercentage = $startingWeight > 0 ? round(($validated['rosin_45u'] / $startingWeight) * 100, 2) : 0;
|
||||
$greenBlonde160Percentage = $startingWeight > 0 ? round(($validated['green_blonde_160u'] / $startingWeight) * 100, 2) : 0;
|
||||
$greenBlonde25Percentage = $startingWeight > 0 ? round(($validated['green_blonde_25u'] / $startingWeight) * 100, 2) : 0;
|
||||
|
||||
// Calculate total yield
|
||||
$totalYield = $validated['fresh_press_120u']
|
||||
+ $validated['cold_cure_90u']
|
||||
+ $validated['rosin_45u']
|
||||
+ $validated['green_blonde_160u']
|
||||
+ $validated['green_blonde_25u'];
|
||||
|
||||
// Update conversion with Stage 2 data
|
||||
$metadata = $conversion->metadata;
|
||||
$metadata['stage_2'] = [
|
||||
'yields' => [
|
||||
'fresh_press_120u' => [
|
||||
'weight' => (float) $validated['fresh_press_120u'],
|
||||
'percentage' => $freshPressPercentage,
|
||||
],
|
||||
'cold_cure_90u' => [
|
||||
'weight' => (float) $validated['cold_cure_90u'],
|
||||
'percentage' => $coldCurePercentage,
|
||||
],
|
||||
'rosin_45u' => [
|
||||
'weight' => (float) $validated['rosin_45u'],
|
||||
'percentage' => $rosinPercentage,
|
||||
],
|
||||
'green_blonde_160u' => [
|
||||
'weight' => (float) $validated['green_blonde_160u'],
|
||||
'percentage' => $greenBlonde160Percentage,
|
||||
],
|
||||
'green_blonde_25u' => [
|
||||
'weight' => (float) $validated['green_blonde_25u'],
|
||||
'percentage' => $greenBlonde25Percentage,
|
||||
],
|
||||
],
|
||||
'total_yield' => $totalYield,
|
||||
];
|
||||
|
||||
$conversion->metadata = $metadata;
|
||||
$conversion->actual_output_quantity = $totalYield;
|
||||
$conversion->actual_output_unit = 'g';
|
||||
$conversion->save();
|
||||
|
||||
return redirect()->route('seller.business.wash-reports.show', [
|
||||
'business' => $business->slug,
|
||||
'conversion' => $conversion->id,
|
||||
])->with('success', 'Wash report completed successfully! Total yield: '.$totalYield.'g ('.$conversion->getYieldPercentage().'%)');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a single wash report
|
||||
*/
|
||||
public function show(Business $business, Conversion $conversion)
|
||||
{
|
||||
// Verify conversion belongs to business
|
||||
if ($conversion->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$conversion->load(['operator', 'inputBatches', 'batchCreated']);
|
||||
|
||||
return view('seller.wash-reports.show', compact('business', 'conversion'));
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
61
app/Jobs/ProcessAnalyticsEvent.php
Normal file
61
app/Jobs/ProcessAnalyticsEvent.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Analytics\AnalyticsEvent;
|
||||
use App\Models\Analytics\UserSession;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
|
||||
class ProcessAnalyticsEvent implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
public array $eventData
|
||||
) {}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
// Create the analytics event
|
||||
$event = AnalyticsEvent::create($this->eventData);
|
||||
|
||||
// Update related session if exists
|
||||
if (! empty($this->eventData['session_id'])) {
|
||||
$session = UserSession::where('session_id', $this->eventData['session_id'])->first();
|
||||
|
||||
if ($session) {
|
||||
$session->updateActivity();
|
||||
|
||||
// Update session metrics based on event type
|
||||
match ($this->eventData['event_type']) {
|
||||
'product_view' => $session->increment('product_views'),
|
||||
'cart_add' => $session->update(['cart_additions' => $session->cart_additions + 1]),
|
||||
'checkout_initiated' => $session->update(['checkout_initiated' => true]),
|
||||
'order_completed' => $session->update(['order_completed' => true]),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger engagement score recalculation if needed
|
||||
if ($this->shouldRecalculateEngagement($event)) {
|
||||
CalculateEngagementScore::dispatch(
|
||||
$event->business_id,
|
||||
$this->eventData['buyer_business_id'] ?? $event->business_id
|
||||
)->onQueue('analytics');
|
||||
}
|
||||
}
|
||||
|
||||
protected function shouldRecalculateEngagement(AnalyticsEvent $event): bool
|
||||
{
|
||||
// Recalculate on significant events
|
||||
return in_array($event->event_type, [
|
||||
'product_view',
|
||||
'email_open',
|
||||
'email_click',
|
||||
'cart_add',
|
||||
'order_completed',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ class Address extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
// Address Types (LeafLink-aligned)
|
||||
// Address Types
|
||||
public const ADDRESS_TYPES = [
|
||||
'corporate' => 'Corporate Headquarters',
|
||||
'physical' => 'Physical Location',
|
||||
|
||||
100
app/Models/Analytics/AnalyticsEvent.php
Normal file
100
app/Models/Analytics/AnalyticsEvent.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Analytics;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class AnalyticsEvent extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
const CREATED_AT = 'created_at';
|
||||
|
||||
const UPDATED_AT = null;
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'event_type',
|
||||
'event_category',
|
||||
'event_action',
|
||||
'subject_id',
|
||||
'subject_type',
|
||||
'user_id',
|
||||
'session_id',
|
||||
'fingerprint',
|
||||
'url',
|
||||
'referrer',
|
||||
'utm_source',
|
||||
'utm_medium',
|
||||
'utm_campaign',
|
||||
'utm_content',
|
||||
'utm_term',
|
||||
'user_agent',
|
||||
'device_type',
|
||||
'browser',
|
||||
'os',
|
||||
'ip_address',
|
||||
'country_code',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'metadata' => 'array',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* Boot the model and apply global scopes
|
||||
*/
|
||||
protected static function booted(): void
|
||||
{
|
||||
parent::booted();
|
||||
|
||||
// Auto-set business_id on creation
|
||||
static::creating(function ($model) {
|
||||
if (! $model->business_id) {
|
||||
$model->business_id = currentBusinessId();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Relationships
|
||||
*/
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scopes
|
||||
*/
|
||||
public function scopeForBusiness($query, $businessId)
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
public function scopeOfType($query, string $type)
|
||||
{
|
||||
return $query->where('event_type', $type);
|
||||
}
|
||||
|
||||
public function scopeForSubject($query, string $type, int $id)
|
||||
{
|
||||
return $query->where('subject_type', $type)
|
||||
->where('subject_id', $id);
|
||||
}
|
||||
|
||||
public function scopeDateRange($query, $startDate, $endDate)
|
||||
{
|
||||
return $query->whereBetween('created_at', [$startDate, $endDate]);
|
||||
}
|
||||
}
|
||||
134
app/Models/Analytics/BuyerEngagementScore.php
Normal file
134
app/Models/Analytics/BuyerEngagementScore.php
Normal file
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Analytics;
|
||||
|
||||
use App\Models\Business;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class BuyerEngagementScore extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'buyer_business_id',
|
||||
'score',
|
||||
'score_tier',
|
||||
'recency_score',
|
||||
'frequency_score',
|
||||
'depth_score',
|
||||
'intent_score',
|
||||
'first_interaction_at',
|
||||
'last_interaction_at',
|
||||
'days_since_last_interaction',
|
||||
'sessions_30d',
|
||||
'page_views_30d',
|
||||
'product_views_30d',
|
||||
'total_orders',
|
||||
'total_revenue',
|
||||
'last_order_at',
|
||||
'days_since_last_order',
|
||||
'calculated_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'total_revenue' => 'decimal:2',
|
||||
'last_interaction_at' => 'datetime',
|
||||
'first_interaction_at' => 'datetime',
|
||||
'last_order_at' => 'datetime',
|
||||
'calculated_at' => 'datetime',
|
||||
];
|
||||
|
||||
// Engagement trends
|
||||
const TREND_INCREASING = 'increasing';
|
||||
|
||||
const TREND_STABLE = 'stable';
|
||||
|
||||
const TREND_DECLINING = 'declining';
|
||||
|
||||
const TREND_NEW = 'new';
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
parent::booted();
|
||||
|
||||
static::creating(function ($model) {
|
||||
if (! $model->business_id) {
|
||||
$model->business_id = currentBusinessId();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function buyerBusiness(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class, 'buyer_business_id');
|
||||
}
|
||||
|
||||
public function intentSignals(): HasMany
|
||||
{
|
||||
return $this->hasMany(IntentSignal::class, 'buyer_business_id', 'buyer_business_id')
|
||||
->where('business_id', $this->business_id);
|
||||
}
|
||||
|
||||
public function scopeForBusiness($query, $businessId)
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
public function scopeHighValue($query)
|
||||
{
|
||||
return $query->where('score', '>=', 70);
|
||||
}
|
||||
|
||||
public function scopeAtRisk($query)
|
||||
{
|
||||
return $query->where('score_tier', 'at_risk')
|
||||
->where('days_since_last_interaction', '>', 30);
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('days_since_last_interaction', '<=', 30);
|
||||
}
|
||||
|
||||
public function scopeByTier($query, string $tier)
|
||||
{
|
||||
return $query->where('score_tier', $tier);
|
||||
}
|
||||
|
||||
public function calculateScore()
|
||||
{
|
||||
// Weighted scoring algorithm
|
||||
$this->score = min(100, (
|
||||
($this->recency_score * 0.25) +
|
||||
($this->frequency_score * 0.25) +
|
||||
($this->depth_score * 0.30) +
|
||||
($this->intent_score * 0.20)
|
||||
));
|
||||
|
||||
return $this->score;
|
||||
}
|
||||
|
||||
public function updateDaysSinceLastInteraction()
|
||||
{
|
||||
if ($this->last_interaction_at) {
|
||||
$this->days_since_last_interaction = now()->diffInDays($this->last_interaction_at);
|
||||
}
|
||||
}
|
||||
|
||||
public function isHighValue(): bool
|
||||
{
|
||||
return $this->score >= 70;
|
||||
}
|
||||
|
||||
public function isAtRisk(): bool
|
||||
{
|
||||
return $this->score_tier === 'at_risk'
|
||||
&& $this->days_since_last_interaction > 30;
|
||||
}
|
||||
}
|
||||
74
app/Models/Analytics/ClickTracking.php
Normal file
74
app/Models/Analytics/ClickTracking.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Analytics;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ClickTracking extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
const CREATED_AT = 'clicked_at';
|
||||
|
||||
const UPDATED_AT = null;
|
||||
|
||||
protected $table = 'click_tracking';
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'user_id',
|
||||
'session_id',
|
||||
'element_type',
|
||||
'element_id',
|
||||
'element_label',
|
||||
'url',
|
||||
'page_url',
|
||||
'clicked_at',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'clicked_at' => 'datetime',
|
||||
'metadata' => 'array',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
parent::booted();
|
||||
|
||||
static::creating(function ($model) {
|
||||
if (! $model->business_id) {
|
||||
$model->business_id = currentBusinessId();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function scopeForBusiness($query, $businessId)
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
public function scopeForElement($query, string $type, int $id)
|
||||
{
|
||||
return $query->where('element_type', $type)
|
||||
->where('element_id', $id);
|
||||
}
|
||||
|
||||
public function scopeOnPage($query, string $pageUrl)
|
||||
{
|
||||
return $query->where('page_url', $pageUrl);
|
||||
}
|
||||
}
|
||||
81
app/Models/Analytics/EmailCampaign.php
Normal file
81
app/Models/Analytics/EmailCampaign.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Analytics;
|
||||
|
||||
use App\Models\Business;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class EmailCampaign extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'name',
|
||||
'subject',
|
||||
'content',
|
||||
'status',
|
||||
'scheduled_at',
|
||||
'sent_at',
|
||||
'total_recipients',
|
||||
'total_sent',
|
||||
'total_delivered',
|
||||
'total_opened',
|
||||
'total_clicked',
|
||||
'total_bounced',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'scheduled_at' => 'datetime',
|
||||
'sent_at' => 'datetime',
|
||||
'metadata' => 'array',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
parent::booted();
|
||||
|
||||
static::creating(function ($model) {
|
||||
if (! $model->business_id) {
|
||||
$model->business_id = currentBusinessId();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function interactions(): HasMany
|
||||
{
|
||||
return $this->hasMany(EmailInteraction::class, 'campaign_id');
|
||||
}
|
||||
|
||||
public function scopeForBusiness($query, $businessId)
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
public function getOpenRateAttribute()
|
||||
{
|
||||
if ($this->total_delivered == 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return round(($this->total_opened / $this->total_delivered) * 100, 2);
|
||||
}
|
||||
|
||||
public function getClickRateAttribute()
|
||||
{
|
||||
if ($this->total_delivered == 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return round(($this->total_clicked / $this->total_delivered) * 100, 2);
|
||||
}
|
||||
}
|
||||
59
app/Models/Analytics/EmailClick.php
Normal file
59
app/Models/Analytics/EmailClick.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Analytics;
|
||||
|
||||
use App\Models\Business;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class EmailClick extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
const CREATED_AT = 'clicked_at';
|
||||
|
||||
const UPDATED_AT = null;
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'email_interaction_id',
|
||||
'url',
|
||||
'link_identifier',
|
||||
'clicked_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'clicked_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
parent::booted();
|
||||
|
||||
static::creating(function ($model) {
|
||||
if (! $model->business_id) {
|
||||
$model->business_id = currentBusinessId();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function emailInteraction(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(EmailInteraction::class);
|
||||
}
|
||||
|
||||
public function scopeForBusiness($query, $businessId)
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
public function scopeForUrl($query, string $url)
|
||||
{
|
||||
return $query->where('url', $url);
|
||||
}
|
||||
}
|
||||
157
app/Models/Analytics/EmailInteraction.php
Normal file
157
app/Models/Analytics/EmailInteraction.php
Normal file
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Analytics;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class EmailInteraction extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'campaign_id',
|
||||
'recipient_user_id',
|
||||
'recipient_email',
|
||||
'tracking_token',
|
||||
'sent_at',
|
||||
'delivered_at',
|
||||
'bounced_at',
|
||||
'bounce_reason',
|
||||
'first_opened_at',
|
||||
'last_opened_at',
|
||||
'open_count',
|
||||
'first_clicked_at',
|
||||
'last_clicked_at',
|
||||
'click_count',
|
||||
'email_client',
|
||||
'device_type',
|
||||
'engagement_score',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'sent_at' => 'datetime',
|
||||
'delivered_at' => 'datetime',
|
||||
'bounced_at' => 'datetime',
|
||||
'first_opened_at' => 'datetime',
|
||||
'last_opened_at' => 'datetime',
|
||||
'first_clicked_at' => 'datetime',
|
||||
'last_clicked_at' => 'datetime',
|
||||
'metadata' => 'array',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
parent::booted();
|
||||
|
||||
static::creating(function ($model) {
|
||||
if (! $model->business_id) {
|
||||
$model->business_id = currentBusinessId();
|
||||
}
|
||||
|
||||
if (! $model->tracking_token) {
|
||||
$model->tracking_token = Str::random(64);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function campaign(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(EmailCampaign::class, 'campaign_id');
|
||||
}
|
||||
|
||||
public function recipientUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'recipient_user_id');
|
||||
}
|
||||
|
||||
public function clicks(): HasMany
|
||||
{
|
||||
return $this->hasMany(EmailClick::class, 'email_interaction_id');
|
||||
}
|
||||
|
||||
public function scopeForBusiness($query, $businessId)
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
public function recordOpen(?string $emailClient = null, ?string $deviceType = null)
|
||||
{
|
||||
$now = now();
|
||||
|
||||
if (! $this->first_opened_at) {
|
||||
$this->first_opened_at = $now;
|
||||
}
|
||||
|
||||
$this->last_opened_at = $now;
|
||||
$this->open_count++;
|
||||
|
||||
if ($emailClient) {
|
||||
$this->email_client = $emailClient;
|
||||
}
|
||||
if ($deviceType) {
|
||||
$this->device_type = $deviceType;
|
||||
}
|
||||
|
||||
$this->calculateEngagementScore();
|
||||
$this->save();
|
||||
|
||||
$this->campaign->increment('total_opened');
|
||||
}
|
||||
|
||||
public function recordClick(string $url, ?string $linkIdentifier = null)
|
||||
{
|
||||
$now = now();
|
||||
|
||||
if (! $this->first_clicked_at) {
|
||||
$this->first_clicked_at = $now;
|
||||
}
|
||||
|
||||
$this->last_clicked_at = $now;
|
||||
$this->click_count++;
|
||||
|
||||
$this->calculateEngagementScore();
|
||||
$this->save();
|
||||
|
||||
EmailClick::create([
|
||||
'business_id' => $this->business_id,
|
||||
'email_interaction_id' => $this->id,
|
||||
'url' => $url,
|
||||
'link_identifier' => $linkIdentifier,
|
||||
'clicked_at' => $now,
|
||||
]);
|
||||
|
||||
if ($this->click_count == 1) {
|
||||
$this->campaign->increment('total_clicked');
|
||||
}
|
||||
}
|
||||
|
||||
protected function calculateEngagementScore()
|
||||
{
|
||||
$score = 0;
|
||||
|
||||
if ($this->open_count > 0) {
|
||||
$score += 20;
|
||||
}
|
||||
if ($this->open_count > 2) {
|
||||
$score += 15;
|
||||
}
|
||||
if ($this->click_count > 0) {
|
||||
$score += 40;
|
||||
}
|
||||
if ($this->click_count > 1) {
|
||||
$score += 25;
|
||||
}
|
||||
|
||||
$this->engagement_score = min($score, 100);
|
||||
}
|
||||
}
|
||||
101
app/Models/Analytics/IntentSignal.php
Normal file
101
app/Models/Analytics/IntentSignal.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Analytics;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class IntentSignal extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'buyer_business_id',
|
||||
'user_id',
|
||||
'signal_type',
|
||||
'signal_strength',
|
||||
'subject_type',
|
||||
'subject_id',
|
||||
'session_id',
|
||||
'context',
|
||||
'detected_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'detected_at' => 'datetime',
|
||||
'context' => 'array',
|
||||
];
|
||||
|
||||
// Signal types
|
||||
const TYPE_HIGH_ENGAGEMENT = 'high_engagement';
|
||||
|
||||
const TYPE_REPEAT_VIEWS = 'repeat_views';
|
||||
|
||||
const TYPE_PRICE_CHECK = 'price_check';
|
||||
|
||||
const TYPE_SPEC_DOWNLOAD = 'spec_download';
|
||||
|
||||
const TYPE_CART_ABANDON = 'cart_abandon';
|
||||
|
||||
const TYPE_EMAIL_CLICK = 'email_click';
|
||||
|
||||
const TYPE_SEARCH_PATTERN = 'search_pattern';
|
||||
|
||||
const TYPE_COMPETITOR_COMPARISON = 'competitor_comparison';
|
||||
|
||||
// Signal strengths
|
||||
const STRENGTH_LOW = 10;
|
||||
|
||||
const STRENGTH_MEDIUM = 50;
|
||||
|
||||
const STRENGTH_HIGH = 75;
|
||||
|
||||
const STRENGTH_CRITICAL = 100;
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
parent::booted();
|
||||
|
||||
static::creating(function ($model) {
|
||||
if (! $model->business_id) {
|
||||
$model->business_id = currentBusinessId();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function buyerBusiness(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class, 'buyer_business_id');
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function scopeForBusiness($query, $businessId)
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
public function scopeHighIntent($query)
|
||||
{
|
||||
return $query->where('signal_strength', '>=', self::STRENGTH_HIGH);
|
||||
}
|
||||
|
||||
public function scopeOfType($query, string $type)
|
||||
{
|
||||
return $query->where('signal_type', $type);
|
||||
}
|
||||
|
||||
public function scopeForBuyer($query, int $buyerBusinessId)
|
||||
{
|
||||
return $query->where('buyer_business_id', $buyerBusinessId);
|
||||
}
|
||||
}
|
||||
87
app/Models/Analytics/ProductView.php
Normal file
87
app/Models/Analytics/ProductView.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Analytics;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Models\Product;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ProductView extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'product_id',
|
||||
'user_id',
|
||||
'buyer_business_id',
|
||||
'session_id',
|
||||
'viewed_at',
|
||||
'time_on_page',
|
||||
'scroll_depth',
|
||||
'zoomed_image',
|
||||
'watched_video',
|
||||
'downloaded_spec',
|
||||
'added_to_cart',
|
||||
'added_to_wishlist',
|
||||
'source',
|
||||
'referrer',
|
||||
'utm_campaign',
|
||||
'device_type',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'viewed_at' => 'datetime',
|
||||
'zoomed_image' => 'boolean',
|
||||
'watched_video' => 'boolean',
|
||||
'downloaded_spec' => 'boolean',
|
||||
'added_to_cart' => 'boolean',
|
||||
'added_to_wishlist' => 'boolean',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
parent::booted();
|
||||
|
||||
static::creating(function ($model) {
|
||||
if (! $model->business_id) {
|
||||
$model->business_id = currentBusinessId();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function buyerBusiness(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class, 'buyer_business_id');
|
||||
}
|
||||
|
||||
public function scopeForBusiness($query, $businessId)
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
public function scopeHighEngagement($query)
|
||||
{
|
||||
return $query->where(function ($q) {
|
||||
$q->where('time_on_page', '>', 30)
|
||||
->orWhere('zoomed_image', true)
|
||||
->orWhere('watched_video', true)
|
||||
->orWhere('downloaded_spec', true);
|
||||
});
|
||||
}
|
||||
}
|
||||
100
app/Models/Analytics/UserSession.php
Normal file
100
app/Models/Analytics/UserSession.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Analytics;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class UserSession extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'user_id',
|
||||
'session_id',
|
||||
'fingerprint',
|
||||
'started_at',
|
||||
'ended_at',
|
||||
'last_activity_at',
|
||||
'duration_seconds',
|
||||
'page_views',
|
||||
'product_views',
|
||||
'cart_additions',
|
||||
'checkout_initiated',
|
||||
'order_completed',
|
||||
'entry_url',
|
||||
'exit_url',
|
||||
'referrer',
|
||||
'utm_source',
|
||||
'utm_medium',
|
||||
'utm_campaign',
|
||||
'device_type',
|
||||
'browser',
|
||||
'os',
|
||||
'country_code',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'started_at' => 'datetime',
|
||||
'ended_at' => 'datetime',
|
||||
'last_activity_at' => 'datetime',
|
||||
'checkout_initiated' => 'boolean',
|
||||
'order_completed' => 'boolean',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
parent::booted();
|
||||
|
||||
static::creating(function ($model) {
|
||||
if (! $model->business_id && function_exists('currentBusiness')) {
|
||||
$business = currentBusiness();
|
||||
if ($business) {
|
||||
$model->business_id = $business->id;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function scopeForBusiness($query, $businessId)
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->whereNull('ended_at')
|
||||
->where('last_activity_at', '>', now()->subMinutes(30));
|
||||
}
|
||||
|
||||
public function scopeConverted($query)
|
||||
{
|
||||
return $query->where('order_completed', true);
|
||||
}
|
||||
|
||||
public function endSession()
|
||||
{
|
||||
if (! $this->ended_at) {
|
||||
$this->ended_at = now();
|
||||
$this->duration_seconds = $this->started_at->diffInSeconds($this->ended_at);
|
||||
$this->save();
|
||||
}
|
||||
}
|
||||
|
||||
public function updateActivity()
|
||||
{
|
||||
$this->last_activity_at = now();
|
||||
$this->save();
|
||||
}
|
||||
}
|
||||
@@ -2,40 +2,48 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\BelongsToBusinessViaProduct;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
* DEFINITIVE BATCH MODEL - See BATCH_AND_LAB_SYSTEM.md
|
||||
*
|
||||
* Two types of batches:
|
||||
* 1. Component batches: Tested input material (flower, rosin, etc.)
|
||||
* 2. Homogenized batches: Mixed components requiring new testing
|
||||
*/
|
||||
class Batch extends Model
|
||||
{
|
||||
use BelongsToBusinessViaProduct, HasFactory, SoftDeletes;
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'product_id',
|
||||
'business_id',
|
||||
'batch_type',
|
||||
'component_id',
|
||||
'batch_number',
|
||||
'internal_code',
|
||||
'quantity_total',
|
||||
'quantity_remaining',
|
||||
'quantity_unit',
|
||||
'primary_coa_id',
|
||||
'production_date',
|
||||
'harvest_date',
|
||||
'package_date',
|
||||
'test_date',
|
||||
'expiration_date',
|
||||
'quantity_produced',
|
||||
'quantity_available',
|
||||
'quantity_allocated',
|
||||
'quantity_sold',
|
||||
'is_active',
|
||||
'is_tested',
|
||||
'is_quarantined',
|
||||
'qr_code_path',
|
||||
'notes',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'production_date' => 'date',
|
||||
'harvest_date' => 'date',
|
||||
'package_date' => 'date',
|
||||
'test_date' => 'date',
|
||||
'expiration_date' => 'date',
|
||||
'is_active' => 'boolean',
|
||||
'is_tested' => 'boolean',
|
||||
@@ -44,154 +52,177 @@ class Batch extends Model
|
||||
];
|
||||
|
||||
/**
|
||||
* Relationships
|
||||
* Business that owns this batch
|
||||
*/
|
||||
public function product(): BelongsTo
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Component (for component batches only)
|
||||
*/
|
||||
public function component(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Component::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Primary COA/Lab test for this batch
|
||||
*/
|
||||
public function primaryCoa(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Lab::class, 'primary_coa_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* All lab tests for this batch
|
||||
*/
|
||||
public function labs(): HasMany
|
||||
{
|
||||
return $this->hasMany(Lab::class);
|
||||
}
|
||||
|
||||
public function orderItems(): HasMany
|
||||
/**
|
||||
* SKU variants that use this batch (via pivot)
|
||||
*/
|
||||
public function products(): BelongsToMany
|
||||
{
|
||||
return $this->hasMany(OrderItem::class);
|
||||
return $this->belongsToMany(Product::class, 'product_batches')
|
||||
->withPivot('quantity_allocated', 'is_active')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
/**
|
||||
* Scopes
|
||||
* For homogenized batches: source component batches used
|
||||
*/
|
||||
public function sourceComponents(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(
|
||||
Batch::class,
|
||||
'batch_source_components',
|
||||
'homogenized_batch_id',
|
||||
'source_batch_id'
|
||||
)->withPivot('quantity_used', 'unit')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
/**
|
||||
* For component batches: homogenized batches that used this as source
|
||||
*/
|
||||
public function usedInHomogenizedBatches(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(
|
||||
Batch::class,
|
||||
'batch_source_components',
|
||||
'source_batch_id',
|
||||
'homogenized_batch_id'
|
||||
)->withPivot('quantity_used', 'unit')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope: Component batches only
|
||||
*/
|
||||
public function scopeComponent($query)
|
||||
{
|
||||
return $query->where('batch_type', 'component');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope: Homogenized batches only
|
||||
*/
|
||||
public function scopeHomogenized($query)
|
||||
{
|
||||
return $query->where('batch_type', 'homogenized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope: Active batches
|
||||
*/
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true)
|
||||
->where('is_quarantined', false);
|
||||
}
|
||||
|
||||
public function scopeAvailable($query)
|
||||
{
|
||||
return $query->active()
|
||||
->where('quantity_available', '>', 0);
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope: Tested batches
|
||||
*/
|
||||
public function scopeTested($query)
|
||||
{
|
||||
return $query->where('is_tested', true);
|
||||
}
|
||||
|
||||
public function scopeExpired($query)
|
||||
/**
|
||||
* Check if batch is component type
|
||||
*/
|
||||
public function isComponent(): bool
|
||||
{
|
||||
return $query->whereNotNull('expiration_date')
|
||||
->where('expiration_date', '<', now());
|
||||
return $this->batch_type === 'component';
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper methods
|
||||
* Check if batch is homogenized type
|
||||
*/
|
||||
|
||||
/**
|
||||
* Check if batch is available for purchase
|
||||
*/
|
||||
public function isAvailableForPurchase(): bool
|
||||
public function isHomogenized(): bool
|
||||
{
|
||||
return $this->is_active
|
||||
&& ! $this->is_quarantined
|
||||
&& $this->quantity_available > 0
|
||||
&& ($this->expiration_date === null || $this->expiration_date > now());
|
||||
return $this->batch_type === 'homogenized';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the most recent lab test for this batch
|
||||
* Check if batch is depleted
|
||||
*/
|
||||
public function getLatestLab()
|
||||
public function isDepleted(): bool
|
||||
{
|
||||
return $this->labs()->latest('test_date')->first();
|
||||
return $this->quantity_remaining <= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocate quantity for an order (reserves inventory)
|
||||
* Get all COAs for this batch (primary + source COAs for homogenized)
|
||||
*/
|
||||
public function allocate(int $quantity): bool
|
||||
public function getAllCoas()
|
||||
{
|
||||
if ($this->quantity_available < $quantity) {
|
||||
$coas = collect();
|
||||
|
||||
// Add primary COA
|
||||
if ($this->primaryCoa) {
|
||||
$coas->push([
|
||||
'type' => 'primary',
|
||||
'coa' => $this->primaryCoa,
|
||||
]);
|
||||
}
|
||||
|
||||
// If homogenized, add source component COAs
|
||||
if ($this->isHomogenized()) {
|
||||
foreach ($this->sourceComponents as $sourceComponent) {
|
||||
if ($sourceComponent->primaryCoa) {
|
||||
$coas->push([
|
||||
'type' => 'source',
|
||||
'coa' => $sourceComponent->primaryCoa,
|
||||
'component' => $sourceComponent,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $coas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce quantity remaining
|
||||
*/
|
||||
public function consume(int $quantity): bool
|
||||
{
|
||||
if ($this->quantity_remaining < $quantity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->decrement('quantity_available', $quantity);
|
||||
$this->increment('quantity_allocated', $quantity);
|
||||
$this->quantity_remaining -= $quantity;
|
||||
$this->save();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release allocated quantity (e.g., when order is cancelled)
|
||||
*/
|
||||
public function releaseAllocation(int $quantity): void
|
||||
{
|
||||
$this->increment('quantity_available', $quantity);
|
||||
$this->decrement('quantity_allocated', $quantity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark quantity as sold (moves from allocated to sold)
|
||||
*/
|
||||
public function markSold(int $quantity): void
|
||||
{
|
||||
$this->decrement('quantity_allocated', $quantity);
|
||||
$this->increment('quantity_sold', $quantity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get batch availability summary
|
||||
*/
|
||||
public function getAvailabilitySummary(): array
|
||||
{
|
||||
return [
|
||||
'total_produced' => $this->quantity_produced,
|
||||
'available' => $this->quantity_available,
|
||||
'allocated' => $this->quantity_allocated,
|
||||
'sold' => $this->quantity_sold,
|
||||
'remaining_percentage' => $this->quantity_produced > 0
|
||||
? round(($this->quantity_available / $this->quantity_produced) * 100, 1)
|
||||
: 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if batch is expiring soon (within 30 days)
|
||||
*/
|
||||
public function isExpiringSoon(): bool
|
||||
{
|
||||
if (! $this->expiration_date) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->expiration_date <= now()->addDays(30)
|
||||
&& $this->expiration_date > now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if batch is expired
|
||||
*/
|
||||
public function isExpired(): bool
|
||||
{
|
||||
if (! $this->expiration_date) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->expiration_date < now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the primary lab test (most recent or most complete)
|
||||
*/
|
||||
public function getPrimaryLabAttribute()
|
||||
{
|
||||
return $this->getLatestLab();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format batch number for display
|
||||
*/
|
||||
|
||||
@@ -32,21 +32,33 @@ class Brand extends Model
|
||||
'business_id',
|
||||
|
||||
// Brand Identity
|
||||
'hashid',
|
||||
'name',
|
||||
'slug',
|
||||
'sku_prefix', // SKU prefix for products
|
||||
'description',
|
||||
'long_description',
|
||||
'tagline',
|
||||
|
||||
// Branding Assets
|
||||
'logo_path',
|
||||
'banner_path',
|
||||
'website_url',
|
||||
'colors', // JSON: hex color codes for theming
|
||||
|
||||
// Physical Address
|
||||
'address',
|
||||
'unit_number',
|
||||
'city',
|
||||
'state',
|
||||
'zip_code',
|
||||
'phone',
|
||||
|
||||
// Social Media
|
||||
'instagram_handle',
|
||||
'facebook_url',
|
||||
'twitter_handle',
|
||||
'youtube_url',
|
||||
|
||||
// Display Settings
|
||||
'is_active',
|
||||
@@ -163,7 +175,28 @@ class Brand extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* Get route key (slug for URLs)
|
||||
* Generate slug from name
|
||||
*/
|
||||
public function generateSlug(): string
|
||||
{
|
||||
return Str::slug($this->name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique 5-character hashid
|
||||
*/
|
||||
public function generateHashid(): string
|
||||
{
|
||||
do {
|
||||
$hashid = Str::random(5);
|
||||
} while (self::where('hashid', $hashid)->exists());
|
||||
|
||||
return $hashid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the route key name for Laravel route model binding
|
||||
* Brands use slug for routing (unlike products which use hashid)
|
||||
*/
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
@@ -171,11 +204,12 @@ class Brand extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate slug from name
|
||||
* Get the storage path for this brand's assets
|
||||
* Format: {hashid}/ (e.g., "52kn5/")
|
||||
*/
|
||||
public function generateSlug(): string
|
||||
public function getStoragePath(): string
|
||||
{
|
||||
return Str::slug($this->name);
|
||||
return $this->hashid.'/';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -217,13 +251,54 @@ class Brand extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot method to auto-generate slug
|
||||
* Check if brand has a banner
|
||||
*/
|
||||
public function hasBanner(): bool
|
||||
{
|
||||
return ! empty($this->banner_path) && \Storage::disk('public')->exists($this->banner_path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get public URL for the brand banner
|
||||
*/
|
||||
public function getBannerUrl(): ?string
|
||||
{
|
||||
if (! $this->banner_path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return asset('storage/'.$this->banner_path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete banner file from storage
|
||||
*/
|
||||
public function deleteBannerFile(): bool
|
||||
{
|
||||
if (! $this->banner_path) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$disk = \Storage::disk('public');
|
||||
|
||||
if ($disk->exists($this->banner_path)) {
|
||||
return $disk->delete($this->banner_path);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot method to auto-generate slug and hashid
|
||||
*/
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function ($brand) {
|
||||
if (empty($brand->hashid)) {
|
||||
$brand->hashid = $brand->generateHashid();
|
||||
}
|
||||
if (empty($brand->slug)) {
|
||||
$brand->slug = $brand->generateSlug();
|
||||
}
|
||||
|
||||
@@ -38,6 +38,15 @@ class Business extends Model implements AuditableContract
|
||||
return substr($fullUuid, 0, 18); // 18 chars total (16 hex + 2 hyphens)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the route key name for Laravel route model binding
|
||||
* Businesses use slug for routing
|
||||
*/
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
return 'slug';
|
||||
}
|
||||
|
||||
// User type (buyer/seller/both)
|
||||
public const TYPES = [
|
||||
'buyer' => 'Buyer (Dispensary/Retailer)',
|
||||
@@ -172,6 +181,39 @@ class Business extends Model implements AuditableContract
|
||||
'approved_at',
|
||||
'approved_by',
|
||||
'notes',
|
||||
|
||||
// Order Settings
|
||||
'separate_orders_by_brand',
|
||||
'auto_increment_order_ids',
|
||||
'show_mark_as_paid',
|
||||
'display_crm_license_on_orders',
|
||||
'order_minimum',
|
||||
'default_shipping_charge',
|
||||
'free_shipping_minimum',
|
||||
'order_disclaimer',
|
||||
'order_invoice_footer',
|
||||
'prevent_order_editing',
|
||||
'az_require_patient_count',
|
||||
'az_require_allotment_verification',
|
||||
|
||||
// Invoice Settings
|
||||
'invoice_payable_company_name',
|
||||
'invoice_payable_address',
|
||||
'invoice_payable_city',
|
||||
'invoice_payable_state',
|
||||
'invoice_payable_zipcode',
|
||||
|
||||
// Notification Settings
|
||||
'new_order_email_notifications',
|
||||
'new_order_only_when_no_sales_rep',
|
||||
'new_order_do_not_send_to_admins',
|
||||
'order_accepted_email_notifications',
|
||||
'enable_shipped_emails_for_sales_reps',
|
||||
'platform_inquiry_email_notifications',
|
||||
'enable_manual_order_email_notifications',
|
||||
'manual_order_emails_internal_only',
|
||||
'low_inventory_email_notifications',
|
||||
'certified_seller_status_email_notifications',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@@ -186,9 +228,25 @@ class Business extends Model implements AuditableContract
|
||||
'credit_limit' => 'decimal:2',
|
||||
'tax_rate' => 'decimal:2',
|
||||
'tax_exempt' => 'boolean',
|
||||
// Order Settings
|
||||
'separate_orders_by_brand' => 'boolean',
|
||||
'auto_increment_order_ids' => 'boolean',
|
||||
'show_mark_as_paid' => 'boolean',
|
||||
'display_crm_license_on_orders' => 'boolean',
|
||||
'order_minimum' => 'decimal:2',
|
||||
'default_shipping_charge' => 'decimal:2',
|
||||
'free_shipping_minimum' => 'decimal:2',
|
||||
'az_require_patient_count' => 'boolean',
|
||||
'az_require_allotment_verification' => 'boolean',
|
||||
// Notification Settings
|
||||
'new_order_only_when_no_sales_rep' => 'boolean',
|
||||
'new_order_do_not_send_to_admins' => 'boolean',
|
||||
'enable_shipped_emails_for_sales_reps' => 'boolean',
|
||||
'enable_manual_order_email_notifications' => 'boolean',
|
||||
'manual_order_emails_internal_only' => 'boolean',
|
||||
];
|
||||
|
||||
// LeafLink-aligned Relationships
|
||||
// Relationships
|
||||
public function users(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'business_user')
|
||||
@@ -274,7 +332,7 @@ class Business extends Model implements AuditableContract
|
||||
return $query->whereIn('type', ['buyer', 'both']);
|
||||
}
|
||||
|
||||
// Helper methods (LeafLink-aligned)
|
||||
// Helper methods
|
||||
public function isSeller(): bool
|
||||
{
|
||||
return in_array($this->type, ['seller', 'both']);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\BelongsToBusinessDirectly;
|
||||
use App\Traits\HasHashid;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -13,10 +14,12 @@ use Illuminate\Support\Str;
|
||||
|
||||
class Component extends Model
|
||||
{
|
||||
use BelongsToBusinessDirectly, HasFactory, SoftDeletes;
|
||||
use BelongsToBusinessDirectly, HasFactory, HasHashid, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'hashid',
|
||||
'business_id',
|
||||
'category_id',
|
||||
'name',
|
||||
'slug',
|
||||
'sku',
|
||||
@@ -70,6 +73,14 @@ class Component extends Model
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Component belongs to a category
|
||||
*/
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ComponentCategory::class, 'category_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Products that use this component in their BOM
|
||||
*/
|
||||
|
||||
43
app/Models/ComponentCategory.php
Normal file
43
app/Models/ComponentCategory.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class ComponentCategory extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'name',
|
||||
'description',
|
||||
'slug',
|
||||
'sort_order',
|
||||
'parent_id',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function components(): HasMany
|
||||
{
|
||||
return $this->hasMany(Component::class, 'category_id');
|
||||
}
|
||||
|
||||
public function parent(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ComponentCategory::class, 'parent_id');
|
||||
}
|
||||
|
||||
public function children(): HasMany
|
||||
{
|
||||
return $this->hasMany(ComponentCategory::class, 'parent_id');
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\BelongsToBusinessDirectly;
|
||||
use App\Traits\HasHashid;
|
||||
use DateTime;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -12,9 +13,9 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Contact extends Model
|
||||
{
|
||||
use BelongsToBusinessDirectly, HasFactory, SoftDeletes;
|
||||
use BelongsToBusinessDirectly, HasFactory, HasHashid, SoftDeletes;
|
||||
|
||||
// Contact Types for Cannabis Business (LeafLink-aligned)
|
||||
// Contact Types for Cannabis Business
|
||||
public const CONTACT_TYPES = [
|
||||
'primary' => 'Primary Contact',
|
||||
'owner' => 'Owner/Executive',
|
||||
@@ -42,6 +43,8 @@ class Contact extends Model
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'hashid',
|
||||
|
||||
// Ownership
|
||||
'business_id',
|
||||
'location_id', // Optional - can be business-wide or location-specific
|
||||
|
||||
@@ -14,7 +14,7 @@ class Location extends Model
|
||||
{
|
||||
use BelongsToBusinessDirectly, HasFactory, SoftDeletes;
|
||||
|
||||
// Location Types (LeafLink Facilities)
|
||||
// Location Types
|
||||
public const LOCATION_TYPES = [
|
||||
'dispensary' => 'Retail Dispensary',
|
||||
'cultivation' => 'Cultivation Facility',
|
||||
@@ -219,7 +219,7 @@ class Location extends Model
|
||||
?? $this->addresses()->where('type', 'physical')->first();
|
||||
}
|
||||
|
||||
// Archive/Transfer (LeafLink pattern)
|
||||
// Archive/Transfer
|
||||
public function archive(?string $reason = null)
|
||||
{
|
||||
$this->update([
|
||||
|
||||
208
app/Models/PermissionAuditLog.php
Normal file
208
app/Models/PermissionAuditLog.php
Normal file
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PermissionAuditLog extends Model
|
||||
{
|
||||
// Action types
|
||||
const ACTION_GRANTED = 'granted';
|
||||
|
||||
const ACTION_REVOKED = 'revoked';
|
||||
|
||||
const ACTION_ROLE_CHANGED = 'role_changed';
|
||||
|
||||
const ACTION_BULK_UPDATE = 'bulk_update';
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'actor_user_id',
|
||||
'target_user_id',
|
||||
'action',
|
||||
'permission',
|
||||
'old_role_template',
|
||||
'new_role_template',
|
||||
'permissions_before',
|
||||
'permissions_after',
|
||||
'is_critical',
|
||||
'expires_at',
|
||||
'reason',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'permissions_before' => 'array',
|
||||
'permissions_after' => 'array',
|
||||
'is_critical' => 'boolean',
|
||||
'expires_at' => 'datetime',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
// Timestamps
|
||||
public $timestamps = false; // We only use created_at
|
||||
|
||||
const CREATED_AT = 'created_at';
|
||||
|
||||
const UPDATED_AT = null;
|
||||
|
||||
/**
|
||||
* Boot the model
|
||||
*/
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function ($model) {
|
||||
$model->created_at = now();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Relationships
|
||||
*/
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function actor(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'actor_user_id');
|
||||
}
|
||||
|
||||
public function targetUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'target_user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scopes
|
||||
*/
|
||||
public function scopeForBusiness($query, int $businessId)
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
public function scopeForUser($query, int $userId)
|
||||
{
|
||||
return $query->where('target_user_id', $userId);
|
||||
}
|
||||
|
||||
public function scopeCritical($query)
|
||||
{
|
||||
return $query->where('is_critical', true);
|
||||
}
|
||||
|
||||
public function scopeExpired($query)
|
||||
{
|
||||
return $query->where('is_critical', false)
|
||||
->whereNotNull('expires_at')
|
||||
->where('expires_at', '<=', now());
|
||||
}
|
||||
|
||||
public function scopeRecent($query, int $days = 30)
|
||||
{
|
||||
return $query->where('created_at', '>=', now()->subDays($days));
|
||||
}
|
||||
|
||||
public function scopeByAction($query, string $action)
|
||||
{
|
||||
return $query->where('action', $action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper methods
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get formatted action name for display
|
||||
*/
|
||||
public function getActionNameAttribute(): string
|
||||
{
|
||||
return match ($this->action) {
|
||||
self::ACTION_GRANTED => 'Permission Granted',
|
||||
self::ACTION_REVOKED => 'Permission Revoked',
|
||||
self::ACTION_ROLE_CHANGED => 'Role Changed',
|
||||
self::ACTION_BULK_UPDATE => 'Permissions Updated',
|
||||
default => ucfirst($this->action),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable summary of the change
|
||||
*/
|
||||
public function getSummaryAttribute(): string
|
||||
{
|
||||
$actorName = $this->actor ? $this->actor->name : 'System';
|
||||
$targetName = $this->targetUser ? $this->targetUser->name : 'Unknown User';
|
||||
|
||||
return match ($this->action) {
|
||||
self::ACTION_GRANTED => "{$actorName} granted '{$this->permission}' to {$targetName}",
|
||||
self::ACTION_REVOKED => "{$actorName} revoked '{$this->permission}' from {$targetName}",
|
||||
self::ACTION_ROLE_CHANGED => "{$actorName} changed {$targetName}'s role from '{$this->old_role_template}' to '{$this->new_role_template}'",
|
||||
self::ACTION_BULK_UPDATE => "{$actorName} updated permissions for {$targetName}",
|
||||
default => "{$actorName} performed {$this->action} on {$targetName}",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of permissions that were added
|
||||
*/
|
||||
public function getAddedPermissionsAttribute(): array
|
||||
{
|
||||
$before = $this->permissions_before ?? [];
|
||||
$after = $this->permissions_after ?? [];
|
||||
|
||||
return array_values(array_diff($after, $before));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of permissions that were removed
|
||||
*/
|
||||
public function getRemovedPermissionsAttribute(): array
|
||||
{
|
||||
$before = $this->permissions_before ?? [];
|
||||
$after = $this->permissions_after ?? [];
|
||||
|
||||
return array_values(array_diff($before, $after));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this log entry is expired and can be deleted
|
||||
*/
|
||||
public function isExpired(): bool
|
||||
{
|
||||
if ($this->is_critical) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->expires_at) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->expires_at->isPast();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get retention status
|
||||
*/
|
||||
public function getRetentionStatusAttribute(): string
|
||||
{
|
||||
if ($this->is_critical) {
|
||||
return 'Kept forever (critical)';
|
||||
}
|
||||
|
||||
if (! $this->expires_at) {
|
||||
return 'No expiration set';
|
||||
}
|
||||
|
||||
if ($this->expires_at->isPast()) {
|
||||
return 'Expired';
|
||||
}
|
||||
|
||||
return 'Expires '.$this->expires_at->diffForHumans();
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\BelongsToBusinessViaBrand;
|
||||
use App\Traits\HasHashid;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -14,7 +15,7 @@ use OwenIt\Auditing\Contracts\Auditable;
|
||||
|
||||
class Product extends Model implements Auditable
|
||||
{
|
||||
use BelongsToBusinessViaBrand, HasFactory, \OwenIt\Auditing\Auditable, SoftDeletes;
|
||||
use BelongsToBusinessViaBrand, HasFactory, HasHashid, \OwenIt\Auditing\Auditable, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
// Foreign Keys
|
||||
@@ -23,8 +24,10 @@ class Product extends Model implements Auditable
|
||||
'parent_product_id',
|
||||
'packaging_id',
|
||||
'unit_id',
|
||||
'category_id',
|
||||
|
||||
// Product Identity
|
||||
'hashid',
|
||||
'name',
|
||||
'slug',
|
||||
'sku',
|
||||
@@ -214,6 +217,11 @@ class Product extends Model implements Auditable
|
||||
return $this->belongsTo(Unit::class);
|
||||
}
|
||||
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProductCategory::class, 'category_id');
|
||||
}
|
||||
|
||||
public function parent(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class, 'parent_product_id');
|
||||
|
||||
43
app/Models/ProductCategory.php
Normal file
43
app/Models/ProductCategory.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class ProductCategory extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'name',
|
||||
'description',
|
||||
'slug',
|
||||
'sort_order',
|
||||
'parent_id',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function products(): HasMany
|
||||
{
|
||||
return $this->hasMany(Product::class, 'category_id');
|
||||
}
|
||||
|
||||
public function parent(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProductCategory::class, 'parent_id');
|
||||
}
|
||||
|
||||
public function children(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProductCategory::class, 'parent_id');
|
||||
}
|
||||
}
|
||||
@@ -26,4 +26,16 @@ class ProductImage extends Model
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get public URL for the product image
|
||||
*/
|
||||
public function getUrl(): ?string
|
||||
{
|
||||
if (! $this->path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return asset('storage/'.$this->path);
|
||||
}
|
||||
}
|
||||
|
||||
275
app/Models/ViewAsSession.php
Normal file
275
app/Models/ViewAsSession.php
Normal file
@@ -0,0 +1,275 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ViewAsSession extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'original_user_id',
|
||||
'viewing_as_user_id',
|
||||
'session_id',
|
||||
'started_at',
|
||||
'ended_at',
|
||||
'duration_seconds',
|
||||
'pages_viewed',
|
||||
'pages_accessed',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'started_at' => 'datetime',
|
||||
'ended_at' => 'datetime',
|
||||
'pages_accessed' => 'array',
|
||||
'pages_viewed' => 'integer',
|
||||
'duration_seconds' => 'integer',
|
||||
];
|
||||
|
||||
// No timestamps table - we track started_at and ended_at manually
|
||||
public $timestamps = false;
|
||||
|
||||
/**
|
||||
* Relationships
|
||||
*/
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function originalUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'original_user_id');
|
||||
}
|
||||
|
||||
public function viewingAsUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'viewing_as_user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scopes
|
||||
*/
|
||||
public function scopeForBusiness($query, int $businessId)
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->whereNull('ended_at');
|
||||
}
|
||||
|
||||
public function scopeEnded($query)
|
||||
{
|
||||
return $query->whereNotNull('ended_at');
|
||||
}
|
||||
|
||||
public function scopeForOriginalUser($query, int $userId)
|
||||
{
|
||||
return $query->where('original_user_id', $userId);
|
||||
}
|
||||
|
||||
public function scopeForViewingAsUser($query, int $userId)
|
||||
{
|
||||
return $query->where('viewing_as_user_id', $userId);
|
||||
}
|
||||
|
||||
public function scopeRecent($query, int $days = 30)
|
||||
{
|
||||
return $query->where('started_at', '>=', now()->subDays($days));
|
||||
}
|
||||
|
||||
public function scopeBySessionId($query, string $sessionId)
|
||||
{
|
||||
return $query->where('session_id', $sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper methods
|
||||
*/
|
||||
|
||||
/**
|
||||
* Check if session is currently active
|
||||
*/
|
||||
public function isActive(): bool
|
||||
{
|
||||
return is_null($this->ended_at);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if session has ended
|
||||
*/
|
||||
public function hasEnded(): bool
|
||||
{
|
||||
return ! is_null($this->ended_at);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if session is expired (based on config timeout)
|
||||
*/
|
||||
public function isExpired(): bool
|
||||
{
|
||||
if ($this->hasEnded()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$timeoutMinutes = config('permissions.view_as.timeout_minutes', 60);
|
||||
$expiresAt = $this->started_at->addMinutes($timeoutMinutes);
|
||||
|
||||
return $expiresAt->isPast();
|
||||
}
|
||||
|
||||
/**
|
||||
* End the session
|
||||
*/
|
||||
public function end(): void
|
||||
{
|
||||
if ($this->hasEnded()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->ended_at = now();
|
||||
$this->duration_seconds = $this->started_at->diffInSeconds($this->ended_at);
|
||||
$this->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a page visit
|
||||
*/
|
||||
public function trackPage(string $url): void
|
||||
{
|
||||
$pages = $this->pages_accessed ?? [];
|
||||
$pages[] = [
|
||||
'url' => $url,
|
||||
'visited_at' => now()->toISOString(),
|
||||
];
|
||||
|
||||
$this->pages_accessed = $pages;
|
||||
$this->pages_viewed = count($pages);
|
||||
$this->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get formatted duration
|
||||
*/
|
||||
public function getFormattedDurationAttribute(): string
|
||||
{
|
||||
if ($this->isActive()) {
|
||||
$duration = $this->started_at->diffInSeconds(now());
|
||||
} else {
|
||||
$duration = $this->duration_seconds ?? 0;
|
||||
}
|
||||
|
||||
$hours = floor($duration / 3600);
|
||||
$minutes = floor(($duration % 3600) / 60);
|
||||
$seconds = $duration % 60;
|
||||
|
||||
if ($hours > 0) {
|
||||
return sprintf('%dh %dm %ds', $hours, $minutes, $seconds);
|
||||
} elseif ($minutes > 0) {
|
||||
return sprintf('%dm %ds', $minutes, $seconds);
|
||||
} else {
|
||||
return sprintf('%ds', $seconds);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session status
|
||||
*/
|
||||
public function getStatusAttribute(): string
|
||||
{
|
||||
if ($this->hasEnded()) {
|
||||
return 'ended';
|
||||
}
|
||||
|
||||
if ($this->isExpired()) {
|
||||
return 'expired';
|
||||
}
|
||||
|
||||
return 'active';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status badge color for UI
|
||||
*/
|
||||
public function getStatusColorAttribute(): string
|
||||
{
|
||||
return match ($this->status) {
|
||||
'active' => 'success',
|
||||
'expired' => 'warning',
|
||||
'ended' => 'ghost',
|
||||
default => 'ghost',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get remaining time before timeout
|
||||
*/
|
||||
public function getRemainingTimeAttribute(): ?string
|
||||
{
|
||||
if ($this->hasEnded()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$timeoutMinutes = config('permissions.view_as.timeout_minutes', 60);
|
||||
$expiresAt = $this->started_at->addMinutes($timeoutMinutes);
|
||||
|
||||
if ($expiresAt->isPast()) {
|
||||
return 'Expired';
|
||||
}
|
||||
|
||||
return $expiresAt->diffForHumans();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find active session by session ID
|
||||
*/
|
||||
public static function findActiveBySessionId(string $sessionId): ?self
|
||||
{
|
||||
return static::bySessionId($sessionId)->active()->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find or create session
|
||||
*/
|
||||
public static function startSession(
|
||||
int $businessId,
|
||||
int $originalUserId,
|
||||
int $viewingAsUserId,
|
||||
string $sessionId
|
||||
): self {
|
||||
// End any existing active sessions for this original user
|
||||
static::forOriginalUser($originalUserId)
|
||||
->forBusiness($businessId)
|
||||
->active()
|
||||
->each(fn ($session) => $session->end());
|
||||
|
||||
// Create new session
|
||||
return static::create([
|
||||
'business_id' => $businessId,
|
||||
'original_user_id' => $originalUserId,
|
||||
'viewing_as_user_id' => $viewingAsUserId,
|
||||
'session_id' => $sessionId,
|
||||
'started_at' => now(),
|
||||
'pages_viewed' => 0,
|
||||
'pages_accessed' => [],
|
||||
'ip_address' => request()->ip(),
|
||||
'user_agent' => request()->userAgent(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get summary for display
|
||||
*/
|
||||
public function getSummaryAttribute(): string
|
||||
{
|
||||
$originalName = $this->originalUser?->name ?? 'Unknown User';
|
||||
$viewingAsName = $this->viewingAsUser?->name ?? 'Unknown User';
|
||||
|
||||
return "{$originalName} viewed as {$viewingAsName}";
|
||||
}
|
||||
}
|
||||
263
app/Services/Analytics/AnalyticsTracker.php
Normal file
263
app/Services/Analytics/AnalyticsTracker.php
Normal file
@@ -0,0 +1,263 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Analytics;
|
||||
|
||||
use App\Models\Analytics\AnalyticsEvent;
|
||||
use App\Models\Analytics\ProductView;
|
||||
use App\Models\Analytics\UserSession;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
|
||||
class AnalyticsTracker
|
||||
{
|
||||
protected $request;
|
||||
|
||||
protected $sessionId;
|
||||
|
||||
protected $fingerprint;
|
||||
|
||||
public function __construct(Request $request)
|
||||
{
|
||||
$this->request = $request;
|
||||
$this->sessionId = Session::getId();
|
||||
$this->fingerprint = $this->generateFingerprint();
|
||||
}
|
||||
|
||||
/**
|
||||
* Track generic analytics event
|
||||
*/
|
||||
public function track(string $eventType, array $data = []): ?AnalyticsEvent
|
||||
{
|
||||
$businessId = $data['business_id'] ?? currentBusinessId();
|
||||
|
||||
if (! $businessId) {
|
||||
return null; // Can't track without business context
|
||||
}
|
||||
|
||||
return AnalyticsEvent::create(array_merge([
|
||||
'business_id' => $businessId,
|
||||
'event_type' => $eventType,
|
||||
'session_id' => $this->sessionId,
|
||||
'fingerprint' => $this->fingerprint,
|
||||
'user_id' => auth()->id(),
|
||||
'url' => $this->request->fullUrl(),
|
||||
'referrer' => $this->request->header('referer'),
|
||||
'user_agent' => $this->request->userAgent(),
|
||||
'ip_address' => $this->request->ip(),
|
||||
'device_type' => $this->detectDeviceType(),
|
||||
], $this->extractUtmParams(), $data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Track product view
|
||||
* IMPORTANT: Product doesn't have business_id, get from Brand
|
||||
*/
|
||||
public function trackProductView($product, array $additionalData = []): ?ProductView
|
||||
{
|
||||
$sellerBusiness = $product->brand?->business;
|
||||
|
||||
if (! $sellerBusiness) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Determine buyer's business if logged in
|
||||
$buyerBusinessId = null;
|
||||
if (auth()->check() && auth()->user()->user_type === 'buyer') {
|
||||
$buyerBusinessId = currentBusinessId();
|
||||
}
|
||||
|
||||
// Create generic event
|
||||
$this->track('product_view', [
|
||||
'business_id' => $sellerBusiness->id,
|
||||
'event_category' => 'product',
|
||||
'event_action' => 'view',
|
||||
'subject_id' => $product->id,
|
||||
'subject_type' => 'Product',
|
||||
]);
|
||||
|
||||
// Create detailed product view
|
||||
return ProductView::create(array_merge([
|
||||
'business_id' => $sellerBusiness->id, // Seller's business
|
||||
'product_id' => $product->id,
|
||||
'user_id' => auth()->id(),
|
||||
'buyer_business_id' => $buyerBusinessId,
|
||||
'session_id' => $this->sessionId,
|
||||
'viewed_at' => now(),
|
||||
'source' => $this->determineSource(),
|
||||
'referrer' => $this->request->header('referer'),
|
||||
'device_type' => $this->detectDeviceType(),
|
||||
], $this->extractUtmParams(), $additionalData));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create session for current user
|
||||
*/
|
||||
public function getOrCreateSession(): ?UserSession
|
||||
{
|
||||
$businessId = currentBusinessId();
|
||||
|
||||
if (! $businessId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return UserSession::firstOrCreate(
|
||||
['session_id' => $this->sessionId],
|
||||
[
|
||||
'business_id' => $businessId,
|
||||
'user_id' => auth()->id(),
|
||||
'buyer_business_id' => auth()->user()?->user_type === 'buyer' ? currentBusinessId() : null,
|
||||
'started_at' => now(),
|
||||
'landing_page' => $this->request->fullUrl(),
|
||||
'referrer' => $this->request->header('referer'),
|
||||
'device_type' => $this->detectDeviceType(),
|
||||
'browser' => $this->detectBrowser(),
|
||||
'os' => $this->detectOS(),
|
||||
'ip_address' => $this->request->ip(),
|
||||
] + $this->extractUtmParams()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment session counter (page views, product views, etc.)
|
||||
*/
|
||||
public function incrementSessionCounter(string $counter): void
|
||||
{
|
||||
$session = $this->getOrCreateSession();
|
||||
if ($session) {
|
||||
$session->increment($counter);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark session as converted
|
||||
*/
|
||||
public function markSessionAsConverted(?float $value = null): void
|
||||
{
|
||||
$session = $this->getOrCreateSession();
|
||||
if ($session) {
|
||||
$session->update([
|
||||
'converted' => true,
|
||||
'conversion_value' => $value,
|
||||
'ended_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract UTM parameters from request
|
||||
*/
|
||||
protected function extractUtmParams(): array
|
||||
{
|
||||
return [
|
||||
'utm_source' => $this->request->get('utm_source'),
|
||||
'utm_medium' => $this->request->get('utm_medium'),
|
||||
'utm_campaign' => $this->request->get('utm_campaign'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine traffic source
|
||||
*/
|
||||
protected function determineSource(): ?string
|
||||
{
|
||||
$referrer = $this->request->header('referer');
|
||||
|
||||
if (! $referrer) {
|
||||
return 'direct';
|
||||
}
|
||||
if ($this->request->has('utm_source')) {
|
||||
return $this->request->get('utm_source');
|
||||
}
|
||||
if (preg_match('/google|bing|yahoo|duckduckgo/i', $referrer)) {
|
||||
return 'search';
|
||||
}
|
||||
if ($this->request->has('email_token')) {
|
||||
return 'email';
|
||||
}
|
||||
|
||||
return 'referral';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate browser fingerprint
|
||||
*/
|
||||
protected function generateFingerprint(): string
|
||||
{
|
||||
$components = [
|
||||
$this->request->userAgent(),
|
||||
$this->request->header('accept-language'),
|
||||
$this->request->header('accept-encoding'),
|
||||
];
|
||||
|
||||
return hash('sha256', implode('|', $components));
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect device type from user agent
|
||||
*/
|
||||
protected function detectDeviceType(): string
|
||||
{
|
||||
$userAgent = $this->request->userAgent();
|
||||
|
||||
if (preg_match('/mobile/i', $userAgent)) {
|
||||
return 'mobile';
|
||||
}
|
||||
if (preg_match('/tablet|ipad/i', $userAgent)) {
|
||||
return 'tablet';
|
||||
}
|
||||
|
||||
return 'desktop';
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect browser from user agent
|
||||
*/
|
||||
protected function detectBrowser(): ?string
|
||||
{
|
||||
$userAgent = $this->request->userAgent();
|
||||
|
||||
if (preg_match('/edg/i', $userAgent)) {
|
||||
return 'Edge';
|
||||
}
|
||||
if (preg_match('/chrome/i', $userAgent)) {
|
||||
return 'Chrome';
|
||||
}
|
||||
if (preg_match('/safari/i', $userAgent)) {
|
||||
return 'Safari';
|
||||
}
|
||||
if (preg_match('/firefox/i', $userAgent)) {
|
||||
return 'Firefox';
|
||||
}
|
||||
if (preg_match('/opera|opr/i', $userAgent)) {
|
||||
return 'Opera';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect operating system from user agent
|
||||
*/
|
||||
protected function detectOS(): ?string
|
||||
{
|
||||
$userAgent = $this->request->userAgent();
|
||||
|
||||
if (preg_match('/windows/i', $userAgent)) {
|
||||
return 'Windows';
|
||||
}
|
||||
if (preg_match('/mac os/i', $userAgent)) {
|
||||
return 'MacOS';
|
||||
}
|
||||
if (preg_match('/linux/i', $userAgent)) {
|
||||
return 'Linux';
|
||||
}
|
||||
if (preg_match('/android/i', $userAgent)) {
|
||||
return 'Android';
|
||||
}
|
||||
if (preg_match('/ios|iphone|ipad/i', $userAgent)) {
|
||||
return 'iOS';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
340
app/Services/AnalyticsTracker.php
Normal file
340
app/Services/AnalyticsTracker.php
Normal file
@@ -0,0 +1,340 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Events\HighIntentBuyerDetected;
|
||||
use App\Helpers\BusinessHelper;
|
||||
use App\Models\Analytics\AnalyticsEvent;
|
||||
use App\Models\Analytics\BuyerEngagementScore;
|
||||
use App\Models\Analytics\ClickTracking;
|
||||
use App\Models\Analytics\IntentSignal;
|
||||
use App\Models\Analytics\ProductView;
|
||||
use App\Models\Analytics\UserSession;
|
||||
use App\Models\Product;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Request;
|
||||
|
||||
class AnalyticsTracker
|
||||
{
|
||||
protected ?string $sessionId = null;
|
||||
|
||||
protected ?int $businessId = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->sessionId = session()->getId();
|
||||
$this->businessId = BusinessHelper::currentId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a product view with engagement signals
|
||||
*/
|
||||
public function trackProductView(
|
||||
Product $product,
|
||||
array $signals = []
|
||||
): ProductView {
|
||||
// Get seller business from product -> brand -> business
|
||||
$sellerBusiness = BusinessHelper::fromProduct($product);
|
||||
|
||||
$productView = ProductView::create([
|
||||
'business_id' => $sellerBusiness->id,
|
||||
'product_id' => $product->id,
|
||||
'user_id' => Auth::id(),
|
||||
'buyer_business_id' => $this->businessId,
|
||||
'session_id' => $this->sessionId,
|
||||
'viewed_at' => now(),
|
||||
'time_on_page' => $signals['time_on_page'] ?? null,
|
||||
'scroll_depth' => $signals['scroll_depth'] ?? null,
|
||||
'zoomed_image' => $signals['zoomed_image'] ?? false,
|
||||
'watched_video' => $signals['watched_video'] ?? false,
|
||||
'downloaded_spec' => $signals['downloaded_spec'] ?? false,
|
||||
'added_to_cart' => $signals['added_to_cart'] ?? false,
|
||||
'added_to_wishlist' => $signals['added_to_wishlist'] ?? false,
|
||||
'source' => $signals['source'] ?? null,
|
||||
'referrer' => Request::header('referer'),
|
||||
'utm_campaign' => Request::input('utm_campaign'),
|
||||
'device_type' => $this->getDeviceType(),
|
||||
]);
|
||||
|
||||
// Also create analytics event
|
||||
$this->trackEvent('product_view', 'product', 'view', $product->id, Product::class);
|
||||
|
||||
// Detect high-intent signals
|
||||
$this->detectIntentSignals($productView, $sellerBusiness->id);
|
||||
|
||||
return $productView;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a click event
|
||||
*/
|
||||
public function trackClick(
|
||||
string $elementType,
|
||||
?int $elementId = null,
|
||||
?string $elementLabel = null,
|
||||
?string $url = null,
|
||||
array $metadata = []
|
||||
): ClickTracking {
|
||||
return ClickTracking::create([
|
||||
'business_id' => $this->businessId,
|
||||
'user_id' => Auth::id(),
|
||||
'session_id' => $this->sessionId,
|
||||
'element_type' => $elementType,
|
||||
'element_id' => $elementId,
|
||||
'element_label' => $elementLabel,
|
||||
'url' => $url,
|
||||
'page_url' => Request::url(),
|
||||
'clicked_at' => now(),
|
||||
'metadata' => $metadata,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track email interaction
|
||||
*/
|
||||
public function trackEmailInteraction(
|
||||
string $campaignId,
|
||||
string $action,
|
||||
array $data = []
|
||||
): void {
|
||||
$this->trackEvent(
|
||||
"email_{$action}",
|
||||
'email',
|
||||
$action,
|
||||
$campaignId,
|
||||
'App\Models\Analytics\EmailCampaign',
|
||||
$data
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a generic analytics event
|
||||
*/
|
||||
public function trackEvent(
|
||||
string $eventType,
|
||||
string $category,
|
||||
string $action,
|
||||
?int $subjectId = null,
|
||||
?string $subjectType = null,
|
||||
array $metadata = []
|
||||
): AnalyticsEvent {
|
||||
return AnalyticsEvent::create([
|
||||
'business_id' => $this->businessId,
|
||||
'event_type' => $eventType,
|
||||
'event_category' => $category,
|
||||
'event_action' => $action,
|
||||
'subject_id' => $subjectId,
|
||||
'subject_type' => $subjectType,
|
||||
'user_id' => Auth::id(),
|
||||
'session_id' => $this->sessionId,
|
||||
'fingerprint' => $this->getFingerprint(),
|
||||
'url' => Request::url(),
|
||||
'referrer' => Request::header('referer'),
|
||||
'utm_source' => Request::input('utm_source'),
|
||||
'utm_medium' => Request::input('utm_medium'),
|
||||
'utm_campaign' => Request::input('utm_campaign'),
|
||||
'utm_content' => Request::input('utm_content'),
|
||||
'utm_term' => Request::input('utm_term'),
|
||||
'user_agent' => Request::header('User-Agent'),
|
||||
'device_type' => $this->getDeviceType(),
|
||||
'browser' => $this->getBrowser(),
|
||||
'os' => $this->getOS(),
|
||||
'ip_address' => Request::ip(),
|
||||
'country_code' => null, // Can be populated via GeoIP service
|
||||
'metadata' => $metadata,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start or update user session
|
||||
*/
|
||||
public function startSession(): UserSession
|
||||
{
|
||||
$session = UserSession::firstOrNew([
|
||||
'session_id' => $this->sessionId,
|
||||
]);
|
||||
|
||||
if (! $session->exists) {
|
||||
$session->fill([
|
||||
'business_id' => $this->businessId,
|
||||
'user_id' => Auth::id(),
|
||||
'fingerprint' => $this->getFingerprint(),
|
||||
'started_at' => now(),
|
||||
'last_activity_at' => now(),
|
||||
'entry_url' => Request::url(),
|
||||
'referrer' => Request::header('referer'),
|
||||
'utm_source' => Request::input('utm_source'),
|
||||
'utm_medium' => Request::input('utm_medium'),
|
||||
'utm_campaign' => Request::input('utm_campaign'),
|
||||
'device_type' => $this->getDeviceType(),
|
||||
'browser' => $this->getBrowser(),
|
||||
'os' => $this->getOS(),
|
||||
'country_code' => null,
|
||||
]);
|
||||
} else {
|
||||
$session->updateActivity();
|
||||
}
|
||||
|
||||
$session->save();
|
||||
|
||||
return $session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update session with page view
|
||||
*/
|
||||
public function updateSessionPageView(): void
|
||||
{
|
||||
$session = UserSession::where('business_id', $this->businessId)
|
||||
->where('session_id', $this->sessionId)
|
||||
->first();
|
||||
|
||||
if ($session) {
|
||||
$session->increment('page_views');
|
||||
$session->updateActivity();
|
||||
$session->exit_url = Request::url();
|
||||
$session->save();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect high-intent signals from product views
|
||||
*/
|
||||
protected function detectIntentSignals(ProductView $productView, int $sellerBusinessId): void
|
||||
{
|
||||
$signals = [];
|
||||
|
||||
// High engagement signal
|
||||
if ($productView->time_on_page > 60 || $productView->zoomed_image || $productView->watched_video) {
|
||||
$signals[] = [
|
||||
'type' => IntentSignal::TYPE_HIGH_ENGAGEMENT,
|
||||
'strength' => IntentSignal::STRENGTH_HIGH,
|
||||
];
|
||||
}
|
||||
|
||||
// Spec download signal (very high intent)
|
||||
if ($productView->downloaded_spec) {
|
||||
$signals[] = [
|
||||
'type' => IntentSignal::TYPE_SPEC_DOWNLOAD,
|
||||
'strength' => IntentSignal::STRENGTH_CRITICAL,
|
||||
];
|
||||
}
|
||||
|
||||
// Check for repeat views
|
||||
$viewCount = ProductView::forBusiness($sellerBusinessId)
|
||||
->where('product_id', $productView->product_id)
|
||||
->where('buyer_business_id', $this->businessId)
|
||||
->count();
|
||||
|
||||
if ($viewCount > 3) {
|
||||
$signals[] = [
|
||||
'type' => IntentSignal::TYPE_REPEAT_VIEWS,
|
||||
'strength' => IntentSignal::STRENGTH_HIGH,
|
||||
];
|
||||
}
|
||||
|
||||
// Create intent signals and broadcast high-intent events
|
||||
foreach ($signals as $signal) {
|
||||
$intentSignal = IntentSignal::create([
|
||||
'business_id' => $sellerBusinessId,
|
||||
'buyer_business_id' => $this->businessId,
|
||||
'user_id' => Auth::id(),
|
||||
'signal_type' => $signal['type'],
|
||||
'signal_strength' => $signal['strength'],
|
||||
'subject_type' => Product::class,
|
||||
'subject_id' => $productView->product_id,
|
||||
'session_id' => $this->sessionId,
|
||||
'detected_at' => now(),
|
||||
'context' => [
|
||||
'product_view_id' => $productView->id,
|
||||
'time_on_page' => $productView->time_on_page,
|
||||
'view_count' => $viewCount ?? 1,
|
||||
],
|
||||
]);
|
||||
|
||||
// Broadcast high-intent signals in real-time
|
||||
if ($signal['strength'] >= IntentSignal::STRENGTH_HIGH) {
|
||||
$engagementScore = BuyerEngagementScore::forBusiness($sellerBusinessId)
|
||||
->where('buyer_business_id', $this->businessId)
|
||||
->first();
|
||||
|
||||
broadcast(new HighIntentBuyerDetected(
|
||||
$sellerBusinessId,
|
||||
$this->businessId,
|
||||
$intentSignal,
|
||||
$engagementScore
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get device type from user agent
|
||||
*/
|
||||
protected function getDeviceType(): string
|
||||
{
|
||||
$userAgent = Request::header('User-Agent');
|
||||
|
||||
if (preg_match('/mobile|android|iphone|ipad|tablet/i', $userAgent)) {
|
||||
return 'mobile';
|
||||
}
|
||||
|
||||
return 'desktop';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get browser from user agent
|
||||
*/
|
||||
protected function getBrowser(): ?string
|
||||
{
|
||||
$userAgent = Request::header('User-Agent');
|
||||
|
||||
if (preg_match('/chrome/i', $userAgent)) {
|
||||
return 'Chrome';
|
||||
} elseif (preg_match('/firefox/i', $userAgent)) {
|
||||
return 'Firefox';
|
||||
} elseif (preg_match('/safari/i', $userAgent)) {
|
||||
return 'Safari';
|
||||
} elseif (preg_match('/edge/i', $userAgent)) {
|
||||
return 'Edge';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OS from user agent
|
||||
*/
|
||||
protected function getOS(): ?string
|
||||
{
|
||||
$userAgent = Request::header('User-Agent');
|
||||
|
||||
if (preg_match('/windows/i', $userAgent)) {
|
||||
return 'Windows';
|
||||
} elseif (preg_match('/mac/i', $userAgent)) {
|
||||
return 'macOS';
|
||||
} elseif (preg_match('/linux/i', $userAgent)) {
|
||||
return 'Linux';
|
||||
} elseif (preg_match('/android/i', $userAgent)) {
|
||||
return 'Android';
|
||||
} elseif (preg_match('/ios|iphone|ipad/i', $userAgent)) {
|
||||
return 'iOS';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate fingerprint for anonymous tracking
|
||||
*/
|
||||
protected function getFingerprint(): string
|
||||
{
|
||||
$components = [
|
||||
Request::ip(),
|
||||
Request::header('User-Agent'),
|
||||
Request::header('Accept-Language'),
|
||||
];
|
||||
|
||||
return hash('sha256', implode('|', $components));
|
||||
}
|
||||
}
|
||||
228
app/Services/ImageBackgroundRemovalService.php
Normal file
228
app/Services/ImageBackgroundRemovalService.php
Normal file
@@ -0,0 +1,228 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ImageBackgroundRemovalService
|
||||
{
|
||||
/**
|
||||
* Remove background from an image
|
||||
* Works best with simple/solid backgrounds
|
||||
*
|
||||
* @param string $imagePath Full path to the image file
|
||||
* @return string|null Path to the processed image, or null on failure
|
||||
*/
|
||||
public function removeBackground(string $imagePath): ?string
|
||||
{
|
||||
try {
|
||||
// Check if GD is available
|
||||
if (! extension_loaded('gd')) {
|
||||
Log::warning('GD extension not loaded, skipping background removal');
|
||||
|
||||
return $imagePath; // Return original if GD not available
|
||||
}
|
||||
|
||||
// Get image info
|
||||
$imageInfo = getimagesize($imagePath);
|
||||
if (! $imageInfo) {
|
||||
Log::warning("Could not read image: {$imagePath}");
|
||||
|
||||
return $imagePath;
|
||||
}
|
||||
|
||||
$mimeType = $imageInfo['mime'];
|
||||
|
||||
// Load image based on type
|
||||
$sourceImage = match ($mimeType) {
|
||||
'image/jpeg', 'image/jpg' => imagecreatefromjpeg($imagePath),
|
||||
'image/png' => imagecreatefrompng($imagePath),
|
||||
default => null
|
||||
};
|
||||
|
||||
if (! $sourceImage) {
|
||||
Log::warning("Unsupported image type: {$mimeType}");
|
||||
|
||||
return $imagePath;
|
||||
}
|
||||
|
||||
$width = imagesx($sourceImage);
|
||||
$height = imagesy($sourceImage);
|
||||
|
||||
// Create a new transparent image
|
||||
$transparentImage = imagecreatetruecolor($width, $height);
|
||||
imagealphablending($transparentImage, false);
|
||||
imagesavealpha($transparentImage, true);
|
||||
|
||||
// Make it fully transparent
|
||||
$transparent = imagecolorallocatealpha($transparentImage, 0, 0, 0, 127);
|
||||
imagefill($transparentImage, 0, 0, $transparent);
|
||||
|
||||
// Get the color of the corners to determine background color
|
||||
// Assuming corners are background
|
||||
$topLeftColor = imagecolorat($sourceImage, 0, 0);
|
||||
$topRightColor = imagecolorat($sourceImage, $width - 1, 0);
|
||||
$bottomLeftColor = imagecolorat($sourceImage, 0, $height - 1);
|
||||
$bottomRightColor = imagecolorat($sourceImage, $width - 1, $height - 1);
|
||||
|
||||
// Use the most common corner color as background
|
||||
$cornerColors = [$topLeftColor, $topRightColor, $bottomLeftColor, $bottomRightColor];
|
||||
$backgroundColorInt = $this->getMostCommonColor($cornerColors);
|
||||
|
||||
// Extract RGB from the background color
|
||||
$backgroundRGB = [
|
||||
'r' => ($backgroundColorInt >> 16) & 0xFF,
|
||||
'g' => ($backgroundColorInt >> 8) & 0xFF,
|
||||
'b' => $backgroundColorInt & 0xFF,
|
||||
];
|
||||
|
||||
// Tolerance for color matching (adjust for better results)
|
||||
// Higher = more aggressive removal, Lower = more conservative
|
||||
$tolerance = 30;
|
||||
|
||||
// Process each pixel
|
||||
for ($y = 0; $y < $height; $y++) {
|
||||
for ($x = 0; $x < $width; $x++) {
|
||||
$pixelColor = imagecolorat($sourceImage, $x, $y);
|
||||
|
||||
$pixelRGB = [
|
||||
'r' => ($pixelColor >> 16) & 0xFF,
|
||||
'g' => ($pixelColor >> 8) & 0xFF,
|
||||
'b' => $pixelColor & 0xFF,
|
||||
];
|
||||
|
||||
// Calculate color difference
|
||||
$colorDiff = abs($pixelRGB['r'] - $backgroundRGB['r'])
|
||||
+ abs($pixelRGB['g'] - $backgroundRGB['g'])
|
||||
+ abs($pixelRGB['b'] - $backgroundRGB['b']);
|
||||
|
||||
// If pixel is similar to background, make it transparent
|
||||
if ($colorDiff <= $tolerance) {
|
||||
imagesetpixel($transparentImage, $x, $y, $transparent);
|
||||
} else {
|
||||
// Keep original pixel
|
||||
$newColor = imagecolorallocate(
|
||||
$transparentImage,
|
||||
$pixelRGB['r'],
|
||||
$pixelRGB['g'],
|
||||
$pixelRGB['b']
|
||||
);
|
||||
imagesetpixel($transparentImage, $x, $y, $newColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save as PNG (to preserve transparency)
|
||||
$outputPath = preg_replace('/\.(jpg|jpeg)$/i', '.png', $imagePath);
|
||||
|
||||
imagepng($transparentImage, $outputPath, 9); // 9 = best compression
|
||||
|
||||
// Clean up
|
||||
imagedestroy($sourceImage);
|
||||
imagedestroy($transparentImage);
|
||||
|
||||
// Delete original if it was converted from JPG to PNG
|
||||
if ($outputPath !== $imagePath && file_exists($imagePath)) {
|
||||
unlink($imagePath);
|
||||
}
|
||||
|
||||
return $outputPath;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Background removal failed: '.$e->getMessage());
|
||||
|
||||
return $imagePath; // Return original on error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the most common color from an array of color integers
|
||||
*/
|
||||
private function getMostCommonColor(array $colors): int
|
||||
{
|
||||
$colorCounts = array_count_values($colors);
|
||||
arsort($colorCounts);
|
||||
|
||||
return array_key_first($colorCounts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alternative method: Remove white/light backgrounds specifically
|
||||
* Better for product photos on white backgrounds
|
||||
*/
|
||||
public function removeWhiteBackground(string $imagePath, int $threshold = 240): ?string
|
||||
{
|
||||
try {
|
||||
if (! extension_loaded('gd')) {
|
||||
Log::warning('GD extension not loaded, skipping background removal');
|
||||
|
||||
return $imagePath;
|
||||
}
|
||||
|
||||
$imageInfo = getimagesize($imagePath);
|
||||
if (! $imageInfo) {
|
||||
return $imagePath;
|
||||
}
|
||||
|
||||
$mimeType = $imageInfo['mime'];
|
||||
|
||||
$sourceImage = match ($mimeType) {
|
||||
'image/jpeg', 'image/jpg' => imagecreatefromjpeg($imagePath),
|
||||
'image/png' => imagecreatefrompng($imagePath),
|
||||
default => null
|
||||
};
|
||||
|
||||
if (! $sourceImage) {
|
||||
return $imagePath;
|
||||
}
|
||||
|
||||
$width = imagesx($sourceImage);
|
||||
$height = imagesy($sourceImage);
|
||||
|
||||
$transparentImage = imagecreatetruecolor($width, $height);
|
||||
imagealphablending($transparentImage, false);
|
||||
imagesavealpha($transparentImage, true);
|
||||
|
||||
$transparent = imagecolorallocatealpha($transparentImage, 0, 0, 0, 127);
|
||||
imagefill($transparentImage, 0, 0, $transparent);
|
||||
|
||||
// Process each pixel
|
||||
for ($y = 0; $y < $height; $y++) {
|
||||
for ($x = 0; $x < $width; $x++) {
|
||||
$pixelColor = imagecolorat($sourceImage, $x, $y);
|
||||
|
||||
$rgb = [
|
||||
'r' => ($pixelColor >> 16) & 0xFF,
|
||||
'g' => ($pixelColor >> 8) & 0xFF,
|
||||
'b' => $pixelColor & 0xFF,
|
||||
];
|
||||
|
||||
// If pixel is white-ish (all RGB values above threshold), make transparent
|
||||
if ($rgb['r'] >= $threshold && $rgb['g'] >= $threshold && $rgb['b'] >= $threshold) {
|
||||
imagesetpixel($transparentImage, $x, $y, $transparent);
|
||||
} else {
|
||||
$newColor = imagecolorallocate($transparentImage, $rgb['r'], $rgb['g'], $rgb['b']);
|
||||
imagesetpixel($transparentImage, $x, $y, $newColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$outputPath = preg_replace('/\.(jpg|jpeg)$/i', '.png', $imagePath);
|
||||
imagepng($transparentImage, $outputPath, 9);
|
||||
|
||||
imagedestroy($sourceImage);
|
||||
imagedestroy($transparentImage);
|
||||
|
||||
if ($outputPath !== $imagePath && file_exists($imagePath)) {
|
||||
unlink($imagePath);
|
||||
}
|
||||
|
||||
return $outputPath;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('White background removal failed: '.$e->getMessage());
|
||||
|
||||
return $imagePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
450
app/Services/PermissionService.php
Normal file
450
app/Services/PermissionService.php
Normal file
@@ -0,0 +1,450 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Models\PermissionAuditLog;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PermissionService
|
||||
{
|
||||
/**
|
||||
* Check if user has a specific permission for current business
|
||||
*/
|
||||
public function check(User $user, string $permission, ?Business $business = null): bool
|
||||
{
|
||||
try {
|
||||
// Get business context
|
||||
$business = $business ?? currentBusiness();
|
||||
if (! $business) {
|
||||
Log::warning('Permission check without business context', [
|
||||
'user_id' => $user->id,
|
||||
'permission' => $permission,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Super admin bypass
|
||||
if ($user->user_type === 'admin') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Business owner bypass
|
||||
if ($business->owner_user_id === $user->id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get user's permissions for this business
|
||||
$businessUser = $user->businesses()
|
||||
->where('businesses.id', $business->id)
|
||||
->first();
|
||||
|
||||
if (! $businessUser) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$userPermissions = $businessUser->pivot->permissions ?? [];
|
||||
|
||||
// Check permission (supports wildcards)
|
||||
return $this->hasPermissionInList($permission, $userPermissions);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Permission check failed', [
|
||||
'user_id' => $user->id,
|
||||
'permission' => $permission,
|
||||
'business_id' => $business?->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if permission exists in list (supports wildcards)
|
||||
*/
|
||||
protected function hasPermissionInList(string $permission, array $permissionList): bool
|
||||
{
|
||||
// Exact match
|
||||
if (in_array($permission, $permissionList)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Wildcard match (e.g., analytics.* matches analytics.overview)
|
||||
foreach ($permissionList as $userPermission) {
|
||||
if (Str::endsWith($userPermission, '.*')) {
|
||||
$prefix = Str::beforeLast($userPermission, '.*');
|
||||
if (Str::startsWith($permission, $prefix.'.')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant permissions to a user for a business
|
||||
*/
|
||||
public function grant(User $user, array $permissions, ?Business $business = null, ?string $reason = null): bool
|
||||
{
|
||||
$business = $business ?? currentBusiness();
|
||||
if (! $business) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$businessUser = $user->businesses()->where('businesses.id', $business->id)->first();
|
||||
if (! $businessUser) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$currentPermissions = $businessUser->pivot->permissions ?? [];
|
||||
$newPermissions = array_unique(array_merge($currentPermissions, $permissions));
|
||||
|
||||
// Update permissions
|
||||
$user->businesses()->updateExistingPivot($business->id, [
|
||||
'permissions' => $newPermissions,
|
||||
'permissions_updated_at' => now(),
|
||||
]);
|
||||
|
||||
// Audit log for each permission granted
|
||||
foreach ($permissions as $permission) {
|
||||
if (! in_array($permission, $currentPermissions)) {
|
||||
$this->logPermissionChange(
|
||||
business: $business,
|
||||
targetUser: $user,
|
||||
action: PermissionAuditLog::ACTION_GRANTED,
|
||||
permission: $permission,
|
||||
permissionsBefore: $currentPermissions,
|
||||
permissionsAfter: $newPermissions,
|
||||
reason: $reason
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear permission cache
|
||||
$this->clearPermissionCache($user->id, $business->id);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke permissions from a user for a business
|
||||
*/
|
||||
public function revoke(User $user, array $permissions, ?Business $business = null, ?string $reason = null): bool
|
||||
{
|
||||
$business = $business ?? currentBusiness();
|
||||
if (! $business) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$businessUser = $user->businesses()->where('businesses.id', $business->id)->first();
|
||||
if (! $businessUser) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$currentPermissions = $businessUser->pivot->permissions ?? [];
|
||||
$newPermissions = array_diff($currentPermissions, $permissions);
|
||||
|
||||
// Update permissions
|
||||
$user->businesses()->updateExistingPivot($business->id, [
|
||||
'permissions' => array_values($newPermissions),
|
||||
'permissions_updated_at' => now(),
|
||||
]);
|
||||
|
||||
// Audit log for each permission revoked
|
||||
foreach ($permissions as $permission) {
|
||||
if (in_array($permission, $currentPermissions)) {
|
||||
$this->logPermissionChange(
|
||||
business: $business,
|
||||
targetUser: $user,
|
||||
action: PermissionAuditLog::ACTION_REVOKED,
|
||||
permission: $permission,
|
||||
permissionsBefore: $currentPermissions,
|
||||
permissionsAfter: $newPermissions,
|
||||
reason: $reason
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear permission cache
|
||||
$this->clearPermissionCache($user->id, $business->id);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set exact permissions (replaces all existing permissions)
|
||||
*/
|
||||
public function setPermissions(
|
||||
User $user,
|
||||
array $permissions,
|
||||
?Business $business = null,
|
||||
?string $roleTemplate = null,
|
||||
?string $reason = null
|
||||
): bool {
|
||||
$business = $business ?? currentBusiness();
|
||||
if (! $business) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$businessUser = $user->businesses()->where('businesses.id', $business->id)->first();
|
||||
if (! $businessUser) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$currentPermissions = $businessUser->pivot->permissions ?? [];
|
||||
$currentRoleTemplate = $businessUser->pivot->role_template;
|
||||
|
||||
// Update permissions and role template
|
||||
$user->businesses()->updateExistingPivot($business->id, [
|
||||
'permissions' => array_values(array_unique($permissions)),
|
||||
'role_template' => $roleTemplate,
|
||||
'permissions_updated_at' => now(),
|
||||
]);
|
||||
|
||||
// Audit log
|
||||
$action = $roleTemplate && $roleTemplate !== $currentRoleTemplate
|
||||
? PermissionAuditLog::ACTION_ROLE_CHANGED
|
||||
: PermissionAuditLog::ACTION_BULK_UPDATE;
|
||||
|
||||
$this->logPermissionChange(
|
||||
business: $business,
|
||||
targetUser: $user,
|
||||
action: $action,
|
||||
permissionsBefore: $currentPermissions,
|
||||
permissionsAfter: $permissions,
|
||||
oldRoleTemplate: $currentRoleTemplate,
|
||||
newRoleTemplate: $roleTemplate,
|
||||
reason: $reason
|
||||
);
|
||||
|
||||
// Clear permission cache
|
||||
$this->clearPermissionCache($user->id, $business->id);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a role template to a user
|
||||
*
|
||||
* @param bool $merge If true, merges with existing permissions. If false, replaces.
|
||||
* @return array|null The permissions that were applied, or null if template not found
|
||||
*/
|
||||
public function applyRoleTemplate(
|
||||
User $user,
|
||||
string $templateKey,
|
||||
?Business $business = null,
|
||||
bool $merge = false
|
||||
): ?array {
|
||||
$business = $business ?? currentBusiness();
|
||||
if (! $business) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$template = config("permissions.role_templates.{$templateKey}");
|
||||
if (! $template) {
|
||||
Log::warning("Role template not found: {$templateKey}");
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$templatePermissions = $template['permissions'] ?? [];
|
||||
|
||||
// Expand wildcards to full permission list
|
||||
$expandedPermissions = $this->expandWildcards($templatePermissions);
|
||||
|
||||
if ($merge) {
|
||||
// Merge with existing permissions
|
||||
$businessUser = $user->businesses()->where('businesses.id', $business->id)->first();
|
||||
$currentPermissions = $businessUser?->pivot->permissions ?? [];
|
||||
$finalPermissions = array_unique(array_merge($currentPermissions, $expandedPermissions));
|
||||
} else {
|
||||
// Replace existing permissions
|
||||
$finalPermissions = $expandedPermissions;
|
||||
}
|
||||
|
||||
// Set permissions with role template name
|
||||
$this->setPermissions(
|
||||
user: $user,
|
||||
permissions: $finalPermissions,
|
||||
business: $business,
|
||||
roleTemplate: $template['name'] ?? $templateKey,
|
||||
reason: "Applied role template: {$template['name']}"
|
||||
);
|
||||
|
||||
return $finalPermissions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand wildcard permissions to full permission list
|
||||
*/
|
||||
public function expandWildcards(array $permissions): array
|
||||
{
|
||||
$expanded = [];
|
||||
$allPermissions = $this->getAllPermissions();
|
||||
|
||||
foreach ($permissions as $permission) {
|
||||
if (Str::endsWith($permission, '.*')) {
|
||||
// Wildcard - expand to all permissions in that category
|
||||
$prefix = Str::beforeLast($permission, '.*');
|
||||
foreach ($allPermissions as $fullPermission) {
|
||||
if (Str::startsWith($fullPermission, $prefix.'.')) {
|
||||
$expanded[] = $fullPermission;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Regular permission
|
||||
$expanded[] = $permission;
|
||||
}
|
||||
}
|
||||
|
||||
return array_unique($expanded);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available permissions from config
|
||||
*/
|
||||
public function getAllPermissions(): array
|
||||
{
|
||||
$categories = config('permissions.categories', []);
|
||||
$allPermissions = [];
|
||||
|
||||
foreach ($categories as $categoryKey => $category) {
|
||||
foreach (array_keys($category['permissions'] ?? []) as $permission) {
|
||||
$allPermissions[] = $permission;
|
||||
}
|
||||
}
|
||||
|
||||
return $allPermissions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get permissions grouped by category for UI
|
||||
*/
|
||||
public function getPermissionsByCategory(): array
|
||||
{
|
||||
return config('permissions.categories', []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available role templates
|
||||
*/
|
||||
public function getRoleTemplates(): array
|
||||
{
|
||||
return config('permissions.role_templates', []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log permission change to audit trail
|
||||
*/
|
||||
protected function logPermissionChange(
|
||||
Business $business,
|
||||
User $targetUser,
|
||||
string $action,
|
||||
?string $permission = null,
|
||||
?array $permissionsBefore = null,
|
||||
?array $permissionsAfter = null,
|
||||
?string $oldRoleTemplate = null,
|
||||
?string $newRoleTemplate = null,
|
||||
?string $reason = null
|
||||
): void {
|
||||
// Determine if this is a critical permission change
|
||||
$isCritical = $this->isCriticalPermission($permission) ||
|
||||
$this->isCriticalAction($action);
|
||||
|
||||
// Calculate expiration date (null if critical)
|
||||
$expiresAt = $isCritical
|
||||
? null
|
||||
: now()->addDays(config('permissions.audit.retention_days', 90));
|
||||
|
||||
PermissionAuditLog::create([
|
||||
'business_id' => $business->id,
|
||||
'actor_user_id' => auth()->id(),
|
||||
'target_user_id' => $targetUser->id,
|
||||
'action' => $action,
|
||||
'permission' => $permission,
|
||||
'old_role_template' => $oldRoleTemplate,
|
||||
'new_role_template' => $newRoleTemplate,
|
||||
'permissions_before' => $permissionsBefore,
|
||||
'permissions_after' => $permissionsAfter,
|
||||
'is_critical' => $isCritical,
|
||||
'expires_at' => $expiresAt,
|
||||
'reason' => $reason,
|
||||
'ip_address' => request()->ip(),
|
||||
'user_agent' => request()->userAgent(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a permission is critical (should be kept forever)
|
||||
*/
|
||||
protected function isCriticalPermission(?string $permission): bool
|
||||
{
|
||||
if (! $permission) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$criticalPermissions = config('permissions.audit.critical_permissions', []);
|
||||
|
||||
foreach ($criticalPermissions as $criticalPermission) {
|
||||
if ($permission === $criticalPermission) {
|
||||
return true;
|
||||
}
|
||||
// Check wildcard patterns
|
||||
if (Str::endsWith($criticalPermission, '.*')) {
|
||||
$prefix = Str::beforeLast($criticalPermission, '.*');
|
||||
if (Str::startsWith($permission, $prefix.'.')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an action is critical
|
||||
*/
|
||||
protected function isCriticalAction(string $action): bool
|
||||
{
|
||||
$criticalActions = config('permissions.audit.critical_actions', []);
|
||||
|
||||
return in_array($action, $criticalActions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear permission cache for a user
|
||||
*/
|
||||
protected function clearPermissionCache(int $userId, int $businessId): void
|
||||
{
|
||||
Cache::forget("permissions.{$userId}.{$businessId}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's permissions with caching
|
||||
*/
|
||||
public function getUserPermissions(User $user, ?Business $business = null): array
|
||||
{
|
||||
$business = $business ?? currentBusiness();
|
||||
if (! $business) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Cache::remember(
|
||||
"permissions.{$user->id}.{$business->id}",
|
||||
now()->addHours(1),
|
||||
function () use ($user, $business) {
|
||||
$businessUser = $user->businesses()
|
||||
->where('businesses.id', $business->id)
|
||||
->first();
|
||||
|
||||
return $businessUser?->pivot->permissions ?? [];
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
267
app/Services/QrCodeService.php
Normal file
267
app/Services/QrCodeService.php
Normal file
@@ -0,0 +1,267 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Batch;
|
||||
use App\Models\Business;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use SimpleSoftwareIO\QrCode\Facades\QrCode;
|
||||
|
||||
class QrCodeService
|
||||
{
|
||||
/**
|
||||
* Generate QR code for a batch
|
||||
* Links to public COA viewing page
|
||||
*
|
||||
* @param array $options ['size' => 300, 'with_logo' => true]
|
||||
* @return array ['success' => bool, 'path' => string|null, 'url' => string|null, 'message' => string]
|
||||
*/
|
||||
public function generateForBatch(Batch $batch, array $options = []): array
|
||||
{
|
||||
$size = $options['size'] ?? 300;
|
||||
$withLogo = $options['with_logo'] ?? false;
|
||||
|
||||
try {
|
||||
// Generate the URL that QR code will link to
|
||||
$coaUrl = route('public.coa.show', ['batchNumber' => $batch->batch_number]);
|
||||
|
||||
// Generate QR code SVG
|
||||
$qrCodeSvg = QrCode::size($size)
|
||||
->style('round')
|
||||
->margin(1)
|
||||
->generate($coaUrl);
|
||||
|
||||
// Determine storage path
|
||||
$business = $batch->business;
|
||||
$businessUuid = $business?->uuid ?? 'unknown';
|
||||
$fileName = "qr-{$batch->batch_number}-".time().'.svg';
|
||||
$storagePath = "businesses/{$businessUuid}/qr-codes/{$fileName}";
|
||||
|
||||
// Store the QR code
|
||||
Storage::disk('public')->put($storagePath, $qrCodeSvg);
|
||||
|
||||
// Update batch with QR code path
|
||||
$batch->update(['qr_code_path' => $storagePath]);
|
||||
|
||||
$publicUrl = Storage::url($storagePath);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'path' => $storagePath,
|
||||
'url' => $publicUrl,
|
||||
'message' => "QR code generated successfully for batch {$batch->batch_number}",
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
'success' => false,
|
||||
'path' => null,
|
||||
'url' => null,
|
||||
'message' => 'Failed to generate QR code: '.$e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate QR code with brand logo overlay
|
||||
* Creates PNG format for logo support
|
||||
* Priority: Brand logo > Business logo > No logo
|
||||
*
|
||||
* @param string|null $logoPath Path to brand logo (optional override)
|
||||
* @param array $options ['size' => 300, 'logo_size' => 80]
|
||||
*/
|
||||
public function generateWithLogo(Batch $batch, ?string $logoPath = null, array $options = []): array
|
||||
{
|
||||
$size = $options['size'] ?? 300;
|
||||
$logoSize = $options['logo_size'] ?? 80;
|
||||
|
||||
try {
|
||||
// Generate the URL that QR code will link to
|
||||
$coaUrl = route('public.coa.show', ['batchNumber' => $batch->batch_number]);
|
||||
|
||||
// Get business for storage path
|
||||
$business = $batch->business;
|
||||
$businessUuid = $business?->uuid ?? 'unknown';
|
||||
|
||||
// If no logo path provided, try to get from Brand first, then Business
|
||||
if (! $logoPath) {
|
||||
// Priority 1: Get brand logo from product
|
||||
if ($batch->product && $batch->product->brand && $batch->product->brand->hasLogo()) {
|
||||
$logoPath = Storage::disk('public')->path($batch->product->brand->logo_path);
|
||||
}
|
||||
// Priority 2: Fall back to business logo
|
||||
elseif ($business && $business->logo_path) {
|
||||
$logoPath = Storage::disk('public')->path($business->logo_path);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate QR code with logo if available
|
||||
$qrCode = QrCode::size($size)
|
||||
->style('round')
|
||||
->margin(1);
|
||||
|
||||
// Add logo if path exists (centered square in middle of QR code)
|
||||
if ($logoPath && file_exists($logoPath)) {
|
||||
// The second parameter (0.3) controls logo size relative to QR code
|
||||
// The third parameter (true) creates a white background square for the logo
|
||||
$qrCode->merge($logoPath, .3, true);
|
||||
}
|
||||
|
||||
$qrCodePng = $qrCode->format('png')->generate($coaUrl);
|
||||
|
||||
// Determine storage path
|
||||
$fileName = "qr-{$batch->batch_number}-".time().'.png';
|
||||
$storagePath = "businesses/{$businessUuid}/qr-codes/{$fileName}";
|
||||
|
||||
// Store the QR code
|
||||
Storage::disk('public')->put($storagePath, $qrCodePng);
|
||||
|
||||
// Update batch with QR code path
|
||||
$batch->update(['qr_code_path' => $storagePath]);
|
||||
|
||||
$publicUrl = Storage::url($storagePath);
|
||||
|
||||
$logoSource = 'no logo';
|
||||
if ($logoPath && file_exists($logoPath)) {
|
||||
if ($batch->product && $batch->product->brand && $batch->product->brand->hasLogo()) {
|
||||
$logoSource = 'brand logo';
|
||||
} elseif ($business && $business->logo_path) {
|
||||
$logoSource = 'business logo';
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'path' => $storagePath,
|
||||
'url' => $publicUrl,
|
||||
'message' => "QR code generated successfully for batch {$batch->batch_number} ({$logoSource})",
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
'success' => false,
|
||||
'path' => null,
|
||||
'url' => null,
|
||||
'message' => 'Failed to generate QR code with logo: '.$e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk generate QR codes for multiple batches with brand logos
|
||||
*
|
||||
* @return array ['successful' => int, 'failed' => int, 'results' => array]
|
||||
*/
|
||||
public function bulkGenerate(array $batchIds, array $options = []): array
|
||||
{
|
||||
$results = [];
|
||||
$successful = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($batchIds as $batchId) {
|
||||
$batch = Batch::find($batchId);
|
||||
|
||||
if (! $batch) {
|
||||
$failed++;
|
||||
$results[] = [
|
||||
'batch_id' => $batchId,
|
||||
'success' => false,
|
||||
'message' => 'Batch not found',
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Use generateWithLogo to include brand logos
|
||||
$result = $this->generateWithLogo($batch, null, $options);
|
||||
$results[] = array_merge($result, ['batch_id' => $batchId]);
|
||||
|
||||
if ($result['success']) {
|
||||
$successful++;
|
||||
} else {
|
||||
$failed++;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'successful' => $successful,
|
||||
'failed' => $failed,
|
||||
'results' => $results,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate QR code for batch with logo (delete old, create new)
|
||||
*/
|
||||
public function regenerate(Batch $batch, array $options = []): array
|
||||
{
|
||||
// Delete old QR code if exists
|
||||
if ($batch->qr_code_path && Storage::disk('public')->exists($batch->qr_code_path)) {
|
||||
Storage::disk('public')->delete($batch->qr_code_path);
|
||||
}
|
||||
|
||||
// Generate new QR code with brand logo
|
||||
return $this->generateWithLogo($batch, null, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete QR code for batch
|
||||
*/
|
||||
public function delete(Batch $batch): array
|
||||
{
|
||||
try {
|
||||
if ($batch->qr_code_path && Storage::disk('public')->exists($batch->qr_code_path)) {
|
||||
Storage::disk('public')->delete($batch->qr_code_path);
|
||||
$batch->update(['qr_code_path' => null]);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => 'QR code deleted successfully',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'No QR code found for this batch',
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Failed to delete QR code: '.$e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get QR code download response for batch
|
||||
*
|
||||
* @return \Symfony\Component\HttpFoundation\StreamedResponse|null
|
||||
*/
|
||||
public function download(Batch $batch)
|
||||
{
|
||||
if (! $batch->qr_code_path || ! Storage::disk('public')->exists($batch->qr_code_path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$fileName = "QR-{$batch->batch_number}.svg";
|
||||
|
||||
return Storage::disk('public')->download($batch->qr_code_path, $fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate QR code data URL (base64 encoded for inline display)
|
||||
*/
|
||||
public function generateDataUrl(Batch $batch, int $size = 200): ?string
|
||||
{
|
||||
try {
|
||||
$coaUrl = route('public.coa.show', ['batchNumber' => $batch->batch_number]);
|
||||
|
||||
$qrCodeSvg = QrCode::size($size)
|
||||
->style('round')
|
||||
->margin(1)
|
||||
->generate($coaUrl);
|
||||
|
||||
return 'data:image/svg+xml;base64,'.base64_encode($qrCodeSvg);
|
||||
} catch (\Exception $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace App\Services;
|
||||
use App\Mail\Seller\NewOrderReceivedMail;
|
||||
use App\Mail\Seller\OrderCancelledMail;
|
||||
use App\Mail\Seller\PaymentReceivedMail as SellerPaymentReceivedMail;
|
||||
use App\Models\Business;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Order;
|
||||
use App\Models\User;
|
||||
@@ -26,25 +27,294 @@ class SellerNotificationService
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify sellers when a new order is received.
|
||||
* Parse comma-separated email addresses from notification settings.
|
||||
*/
|
||||
public function newOrderReceived(Order $order): void
|
||||
protected function parseEmailList(?string $emailList): array
|
||||
{
|
||||
$sellers = $this->getSellerUsers();
|
||||
if (empty($emailList)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
foreach ($sellers as $seller) {
|
||||
// Send email
|
||||
Mail::to($seller->email)->send(new NewOrderReceivedMail($order));
|
||||
return array_filter(
|
||||
array_map('trim', explode(',', $emailList)),
|
||||
fn ($email) => ! empty($email) && filter_var($email, FILTER_VALIDATE_EMAIL)
|
||||
);
|
||||
}
|
||||
|
||||
// Create in-app notification
|
||||
$this->notificationService->create(
|
||||
user: $seller,
|
||||
type: 'seller_new_order',
|
||||
title: 'New Order Received',
|
||||
message: "New order {$order->order_number} from {$order->business->name}. Total: $".number_format($order->total, 2),
|
||||
actionUrl: route('seller.orders.show', $order),
|
||||
notifiable: $order
|
||||
);
|
||||
/**
|
||||
* Get the seller business from an order (the business that owns the product being sold).
|
||||
*/
|
||||
protected function getSellerBusinessFromOrder(Order $order): ?Business
|
||||
{
|
||||
// Get seller business from first order item's product's brand
|
||||
$firstItem = $order->items()->with('product.brand.business')->first();
|
||||
|
||||
return $firstItem?->product?->brand?->business;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if buyer has any sales reps assigned.
|
||||
*/
|
||||
protected function buyerHasSalesRep(Business $buyer): bool
|
||||
{
|
||||
// TODO: Implement sales rep relationship checking when sales rep system is built
|
||||
// For now, return false (no sales reps assigned)
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sales reps assigned to a buyer.
|
||||
*/
|
||||
protected function getSalesRepsForBuyer(Business $buyer): \Illuminate\Support\Collection
|
||||
{
|
||||
// TODO: Implement sales rep relationship when sales rep system is built
|
||||
// For now, return empty collection
|
||||
return collect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get company admin users for a business.
|
||||
*/
|
||||
protected function getCompanyAdmins(Business $business): \Illuminate\Support\Collection
|
||||
{
|
||||
// Get users associated with this business who have admin role
|
||||
return $business->users()
|
||||
->whereHas('roles', function ($query) {
|
||||
$query->where('name', User::ROLE_SUPER_ADMIN);
|
||||
})
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* NEW ORDER EMAIL NOTIFICATIONS
|
||||
*
|
||||
* RULES:
|
||||
* 1. Base: Email addresses in 'new_order_email_notifications' when new order is placed
|
||||
* 2. If 'new_order_only_when_no_sales_rep' is TRUE: ONLY send if buyer has NO sales rep assigned
|
||||
* 3. If 'new_order_do_not_send_to_admins' is TRUE: Do NOT send to company admins (only to custom addresses)
|
||||
* 4. If 'new_order_do_not_send_to_admins' is FALSE: Send to BOTH custom addresses AND company admins
|
||||
*/
|
||||
public function newOrderReceived(Order $order, bool $isManualOrder = false): void
|
||||
{
|
||||
$sellerBusiness = $this->getSellerBusinessFromOrder($order);
|
||||
if (! $sellerBusiness) {
|
||||
return;
|
||||
}
|
||||
|
||||
$buyerBusiness = $order->business;
|
||||
|
||||
// Check manual order notification settings
|
||||
if ($isManualOrder && ! $sellerBusiness->enable_manual_order_email_notifications) {
|
||||
return; // Don't send notifications for manual orders if disabled
|
||||
}
|
||||
|
||||
// RULE 2: Check if we should only send when buyer has no sales rep
|
||||
if ($sellerBusiness->new_order_only_when_no_sales_rep) {
|
||||
if ($this->buyerHasSalesRep($buyerBusiness)) {
|
||||
return; // Buyer has sales rep, don't send
|
||||
}
|
||||
}
|
||||
|
||||
// RULE 1: Get custom email addresses from settings
|
||||
$customEmails = $this->parseEmailList($sellerBusiness->new_order_email_notifications);
|
||||
|
||||
// RULE 3 & 4: Determine if we should send to admins
|
||||
$sendToAdmins = ! $sellerBusiness->new_order_do_not_send_to_admins;
|
||||
|
||||
// Collect all recipients
|
||||
$recipients = [];
|
||||
|
||||
// Add custom email addresses
|
||||
foreach ($customEmails as $email) {
|
||||
$recipients[] = $email;
|
||||
}
|
||||
|
||||
// Add company admins if enabled
|
||||
if ($sendToAdmins) {
|
||||
$admins = $this->getCompanyAdmins($sellerBusiness);
|
||||
foreach ($admins as $admin) {
|
||||
$recipients[] = $admin->email;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicates
|
||||
$recipients = array_unique($recipients);
|
||||
|
||||
// Send emails
|
||||
foreach ($recipients as $email) {
|
||||
Mail::to($email)->send(new NewOrderReceivedMail($order));
|
||||
}
|
||||
|
||||
// Create in-app notifications for admin users only
|
||||
if ($sendToAdmins) {
|
||||
$admins = $this->getCompanyAdmins($sellerBusiness);
|
||||
foreach ($admins as $admin) {
|
||||
$this->notificationService->create(
|
||||
user: $admin,
|
||||
type: 'seller_new_order',
|
||||
title: 'New Order Received',
|
||||
message: "New order {$order->order_number} from {$buyerBusiness->name}. Total: $".number_format($order->total, 2),
|
||||
actionUrl: route('seller.business.orders.show', [$sellerBusiness->slug, $order]),
|
||||
notifiable: $order
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ORDER ACCEPTED EMAIL NOTIFICATIONS
|
||||
*
|
||||
* RULES:
|
||||
* 1. Base: Email addresses in 'order_accepted_email_notifications' when order is accepted
|
||||
* 2. This notification has no conditional logic - always sends if addresses are configured
|
||||
* 3. Note: 'enable_shipped_emails_for_sales_reps' is for SHIPPED status, not accepted (handled separately)
|
||||
*/
|
||||
public function orderAccepted(Order $order): void
|
||||
{
|
||||
$sellerBusiness = $this->getSellerBusinessFromOrder($order);
|
||||
if (! $sellerBusiness) {
|
||||
return;
|
||||
}
|
||||
|
||||
// RULE 1: Get email addresses from settings
|
||||
$emails = $this->parseEmailList($sellerBusiness->order_accepted_email_notifications);
|
||||
|
||||
if (empty($emails)) {
|
||||
return; // No emails configured
|
||||
}
|
||||
|
||||
// Send emails to all configured addresses
|
||||
foreach ($emails as $email) {
|
||||
// TODO: Create OrderAcceptedMail class
|
||||
// Mail::to($email)->send(new OrderAcceptedMail($order));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ORDER SHIPPED EMAIL NOTIFICATIONS (for sales reps)
|
||||
*
|
||||
* RULES:
|
||||
* 1. If 'enable_shipped_emails_for_sales_reps' is TRUE: Send to sales reps assigned to the buyer
|
||||
* 2. If FALSE: Don't send shipped notifications to sales reps
|
||||
*/
|
||||
public function orderShipped(Order $order): void
|
||||
{
|
||||
$sellerBusiness = $this->getSellerBusinessFromOrder($order);
|
||||
if (! $sellerBusiness) {
|
||||
return;
|
||||
}
|
||||
|
||||
// RULE 1: Check if sales rep shipped emails are enabled
|
||||
if (! $sellerBusiness->enable_shipped_emails_for_sales_reps) {
|
||||
return;
|
||||
}
|
||||
|
||||
$buyerBusiness = $order->business;
|
||||
|
||||
// RULE 1: Get sales reps assigned to this buyer
|
||||
$salesReps = $this->getSalesRepsForBuyer($buyerBusiness);
|
||||
|
||||
if ($salesReps->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Send emails to all sales reps
|
||||
foreach ($salesReps as $salesRep) {
|
||||
// TODO: Create OrderShippedForSalesRepMail class
|
||||
// Mail::to($salesRep->email)->send(new OrderShippedForSalesRepMail($order));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PLATFORM INQUIRY EMAIL NOTIFICATIONS
|
||||
*
|
||||
* RULES:
|
||||
* 1. Sales reps associated with customer ALWAYS receive email
|
||||
* 2. Custom addresses in 'platform_inquiry_email_notifications' ALWAYS receive email
|
||||
* 3. If NO custom addresses AND NO sales reps exist: company admins receive notifications
|
||||
*/
|
||||
public function platformInquiry(Business $buyerBusiness, Business $sellerBusiness, string $inquiryMessage): void
|
||||
{
|
||||
// RULE 1: Get sales reps for this buyer
|
||||
$salesReps = $this->getSalesRepsForBuyer($buyerBusiness);
|
||||
|
||||
// RULE 2: Get custom email addresses
|
||||
$customEmails = $this->parseEmailList($sellerBusiness->platform_inquiry_email_notifications);
|
||||
|
||||
// Collect recipients
|
||||
$recipients = [];
|
||||
|
||||
// Add sales reps (ALWAYS)
|
||||
foreach ($salesReps as $salesRep) {
|
||||
$recipients[] = $salesRep->email;
|
||||
}
|
||||
|
||||
// Add custom emails (ALWAYS if configured)
|
||||
foreach ($customEmails as $email) {
|
||||
$recipients[] = $email;
|
||||
}
|
||||
|
||||
// RULE 3: If no recipients yet, send to company admins
|
||||
if (empty($recipients)) {
|
||||
$admins = $this->getCompanyAdmins($sellerBusiness);
|
||||
foreach ($admins as $admin) {
|
||||
$recipients[] = $admin->email;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicates
|
||||
$recipients = array_unique($recipients);
|
||||
|
||||
// Send emails
|
||||
foreach ($recipients as $email) {
|
||||
// TODO: Create PlatformInquiryMail class
|
||||
// Mail::to($email)->send(new PlatformInquiryMail($buyerBusiness, $inquiryMessage));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* LOW INVENTORY EMAIL NOTIFICATIONS
|
||||
*
|
||||
* RULES:
|
||||
* 1. Base: Email addresses in 'low_inventory_email_notifications' when inventory is low
|
||||
* 2. No conditional logic - straightforward notification
|
||||
*/
|
||||
public function lowInventory(Business $sellerBusiness, $product, int $currentQuantity, int $threshold): void
|
||||
{
|
||||
// RULE 1: Get email addresses from settings
|
||||
$emails = $this->parseEmailList($sellerBusiness->low_inventory_email_notifications);
|
||||
|
||||
if (empty($emails)) {
|
||||
return; // No emails configured
|
||||
}
|
||||
|
||||
// Send emails to all configured addresses
|
||||
foreach ($emails as $email) {
|
||||
// TODO: Create LowInventoryMail class
|
||||
// Mail::to($email)->send(new LowInventoryMail($product, $currentQuantity, $threshold));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CERTIFIED SELLER STATUS EMAIL NOTIFICATIONS
|
||||
*
|
||||
* RULES:
|
||||
* 1. Base: Email addresses in 'certified_seller_status_email_notifications' when status changes
|
||||
* 2. No conditional logic - straightforward notification
|
||||
*/
|
||||
public function certifiedSellerStatusChanged(Business $sellerBusiness, string $oldStatus, string $newStatus): void
|
||||
{
|
||||
// RULE 1: Get email addresses from settings
|
||||
$emails = $this->parseEmailList($sellerBusiness->certified_seller_status_email_notifications);
|
||||
|
||||
if (empty($emails)) {
|
||||
return; // No emails configured
|
||||
}
|
||||
|
||||
// Send emails to all configured addresses
|
||||
foreach ($emails as $email) {
|
||||
// TODO: Create CertifiedSellerStatusChangedMail class
|
||||
// Mail::to($email)->send(new CertifiedSellerStatusChangedMail($sellerBusiness, $oldStatus, $newStatus));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
58
app/Traits/HasHashid.php
Normal file
58
app/Traits/HasHashid.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
trait HasHashid
|
||||
{
|
||||
/**
|
||||
* Boot the trait - automatically generate hashid on creation
|
||||
*/
|
||||
protected static function bootHasHashid(): void
|
||||
{
|
||||
static::creating(function ($model) {
|
||||
if (empty($model->hashid)) {
|
||||
$model->hashid = $model->generateHashid();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique hashid in NNLLN format
|
||||
* Example: 26bf7, 83jk2, 45mn9
|
||||
* Excludes: 0, o, l, i to prevent confusion
|
||||
*/
|
||||
public function generateHashid(): string
|
||||
{
|
||||
$numbers = '123456789'; // Exclude 0
|
||||
$letters = 'abcdefghjkmnpqrstuvwxyz'; // Exclude i, l, o
|
||||
|
||||
do {
|
||||
$hashid = $numbers[rand(0, strlen($numbers) - 1)]
|
||||
.$numbers[rand(0, strlen($numbers) - 1)]
|
||||
.$letters[rand(0, strlen($letters) - 1)]
|
||||
.$letters[rand(0, strlen($letters) - 1)]
|
||||
.$numbers[rand(0, strlen($numbers) - 1)];
|
||||
|
||||
// Check if this hashid already exists
|
||||
$exists = static::where('hashid', $hashid)->exists();
|
||||
} while ($exists);
|
||||
|
||||
return $hashid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the route key for the model (use hashid instead of id)
|
||||
*/
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
return 'hashid';
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope query to find by hashid
|
||||
*/
|
||||
public function scopeByHashid($query, string $hashid)
|
||||
{
|
||||
return $query->where('hashid', $hashid);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Helpers\BusinessHelper;
|
||||
|
||||
if (! function_exists('dashboard_url')) {
|
||||
function dashboard_url(): string
|
||||
{
|
||||
@@ -10,7 +12,27 @@ if (! function_exists('dashboard_url')) {
|
||||
return url('/');
|
||||
}
|
||||
|
||||
// Simple dashboard URL (LeafLink-style)
|
||||
// Simple dashboard URL
|
||||
return route('dashboard');
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('currentBusiness')) {
|
||||
/**
|
||||
* Get the current business for the authenticated user
|
||||
*/
|
||||
function currentBusiness(): ?\App\Models\Business
|
||||
{
|
||||
return BusinessHelper::current();
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('hasBusinessPermission')) {
|
||||
/**
|
||||
* Check if user has permission for current business
|
||||
*/
|
||||
function hasBusinessPermission(string $permission): bool
|
||||
{
|
||||
return BusinessHelper::hasPermission($permission);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,12 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
\Illuminate\Http\Request::HEADER_X_FORWARDED_AWS_ELB
|
||||
);
|
||||
|
||||
// Add View As middleware to web group
|
||||
$middleware->web(append: [
|
||||
\App\Http\Middleware\ViewAsMiddleware::class,
|
||||
\App\Http\Middleware\UpdateLastLogin::class,
|
||||
]);
|
||||
|
||||
$middleware->alias([
|
||||
'approved' => \App\Http\Middleware\EnsureUserApproved::class,
|
||||
'buyer' => \App\Http\Middleware\EnsureUserIsBuyer::class,
|
||||
|
||||
37
check-all-sql-files.php
Normal file
37
check-all-sql-files.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
echo "=== CHECKING ALL SQL FILES FOR BRANDS ===\n\n";
|
||||
|
||||
$sqlFiles = [
|
||||
'hubexport.sql',
|
||||
'hash-factory-data.sql',
|
||||
'export-hash-factory-products.sql',
|
||||
];
|
||||
|
||||
foreach ($sqlFiles as $file) {
|
||||
$path = __DIR__.'/'.$file;
|
||||
if (! file_exists($path)) {
|
||||
echo "$file: NOT FOUND\n";
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$sql = file_get_contents($path);
|
||||
$fileSize = filesize($path);
|
||||
|
||||
echo "$file (".round($fileSize / 1024 / 1024, 2)." MB):\n";
|
||||
|
||||
if (preg_match_all('/INSERT INTO `brands` VALUES\s*\((.*?)\);/s', $sql, $inserts)) {
|
||||
echo ' Brands: '.count($inserts[1])."\n";
|
||||
} else {
|
||||
echo " Brands: 0\n";
|
||||
}
|
||||
|
||||
if (preg_match_all('/INSERT INTO `products` VALUES\s*\((.*?)\);/s', $sql, $inserts)) {
|
||||
echo ' Products: '.count($inserts[1])."\n";
|
||||
} else {
|
||||
echo " Products: 0\n";
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
}
|
||||
187
check-and-import-missing-brand-images.php
Normal file
187
check-and-import-missing-brand-images.php
Normal file
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
require __DIR__.'/vendor/autoload.php';
|
||||
|
||||
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
|
||||
use App\Models\Brand;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
echo "=== CHECKING ALL BRAND IMAGES ===\n\n";
|
||||
|
||||
// Connect to live database
|
||||
$host = 'sql1.creationshop.net';
|
||||
$username = 'claude';
|
||||
$password = 'claude';
|
||||
$database = 'hub_cannabrands';
|
||||
|
||||
try {
|
||||
$conn = new PDO("mysql:host=$host;dbname=$database", $username, $password);
|
||||
$conn->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
echo "✓ Connected to live database\n\n";
|
||||
} catch (PDOException $e) {
|
||||
exit('ERROR: '.$e->getMessage()."\n");
|
||||
}
|
||||
|
||||
// Get all brands from live database
|
||||
$stmt = $conn->query('
|
||||
SELECT name, image, banner
|
||||
FROM brands
|
||||
ORDER BY name
|
||||
');
|
||||
$liveBrands = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
$liveBrandMap = [];
|
||||
foreach ($liveBrands as $liveBrand) {
|
||||
$liveBrandMap[$liveBrand['name']] = $liveBrand;
|
||||
}
|
||||
|
||||
// Get all Cannabrands brands
|
||||
$brands = Brand::where('business_id', 5)->orderBy('name')->get();
|
||||
|
||||
echo 'Found '.count($brands)." brands in PostgreSQL\n\n";
|
||||
|
||||
$missingLogos = [];
|
||||
$missingBanners = [];
|
||||
$imported = 0;
|
||||
|
||||
foreach ($brands as $brand) {
|
||||
$hasLogo = $brand->logo_path && Storage::disk('public')->exists($brand->logo_path);
|
||||
$hasBanner = $brand->banner_path && Storage::disk('public')->exists($brand->banner_path);
|
||||
|
||||
$logoStatus = $hasLogo ? '✓' : '✗';
|
||||
$bannerStatus = $hasBanner ? '✓' : '✗';
|
||||
|
||||
echo "{$brand->name} (Hashid: {$brand->hashid}):\n";
|
||||
echo " Logo: $logoStatus ".($brand->logo_path ?? 'NULL')."\n";
|
||||
echo " Banner: $bannerStatus ".($brand->banner_path ?? 'NULL')."\n";
|
||||
|
||||
// Try to find in live database (handle name variations)
|
||||
$liveData = null;
|
||||
if (isset($liveBrandMap[$brand->name])) {
|
||||
$liveData = $liveBrandMap[$brand->name];
|
||||
} elseif ($brand->name === 'Dairy2Dank' && isset($liveBrandMap['Dairy to Dank'])) {
|
||||
$liveData = $liveBrandMap['Dairy to Dank'];
|
||||
}
|
||||
|
||||
if (! $liveData) {
|
||||
echo " ⚠️ Not found in live database\n\n";
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$needsUpdate = false;
|
||||
|
||||
// Import missing logo
|
||||
if (! $hasLogo && $liveData['image'] && strlen($liveData['image']) > 100) {
|
||||
echo " → Importing logo...\n";
|
||||
|
||||
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
||||
$mimeType = $finfo->buffer($liveData['image']);
|
||||
$extension = match ($mimeType) {
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'image/gif' => 'gif',
|
||||
'image/webp' => 'webp',
|
||||
default => 'png'
|
||||
};
|
||||
|
||||
$newPath = "{$brand->hashid}/logo.{$extension}";
|
||||
|
||||
if (! Storage::disk('public')->exists($brand->hashid)) {
|
||||
Storage::disk('public')->makeDirectory($brand->hashid);
|
||||
}
|
||||
|
||||
Storage::disk('public')->put($newPath, $liveData['image']);
|
||||
$brand->logo_path = $newPath;
|
||||
$needsUpdate = true;
|
||||
$imported++;
|
||||
|
||||
echo " ✓ Logo imported: {$newPath}\n";
|
||||
}
|
||||
|
||||
// Import missing banner
|
||||
if (! $hasBanner && $liveData['banner'] && strlen($liveData['banner']) > 0 && strlen($liveData['banner']) < 200) {
|
||||
echo " → Importing banner...\n";
|
||||
|
||||
$baseUrl = 'https://hub.cannabrands.com/storage/';
|
||||
$bannerUrl = $baseUrl.str_replace(' ', '%20', $liveData['banner']);
|
||||
|
||||
$ch = curl_init($bannerUrl);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
|
||||
$imageData = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode === 200 && $imageData && strlen($imageData) > 100) {
|
||||
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
||||
$mimeType = $finfo->buffer($imageData);
|
||||
$extension = match ($mimeType) {
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'image/gif' => 'gif',
|
||||
'image/webp' => 'webp',
|
||||
default => 'png'
|
||||
};
|
||||
|
||||
$newPath = "{$brand->hashid}/banner.{$extension}";
|
||||
|
||||
if (! Storage::disk('public')->exists($brand->hashid)) {
|
||||
Storage::disk('public')->makeDirectory($brand->hashid);
|
||||
}
|
||||
|
||||
Storage::disk('public')->put($newPath, $imageData);
|
||||
$brand->banner_path = $newPath;
|
||||
$needsUpdate = true;
|
||||
$imported++;
|
||||
|
||||
echo " ✓ Banner imported: {$newPath}\n";
|
||||
} else {
|
||||
echo " ✗ Failed to download banner (HTTP {$httpCode})\n";
|
||||
}
|
||||
}
|
||||
|
||||
if ($needsUpdate) {
|
||||
$brand->save();
|
||||
}
|
||||
|
||||
if (! $hasLogo && ! $liveData['image']) {
|
||||
$missingLogos[] = $brand->name;
|
||||
}
|
||||
if (! $hasBanner && (! $liveData['banner'] || strlen($liveData['banner']) > 200)) {
|
||||
$missingBanners[] = $brand->name;
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
}
|
||||
|
||||
echo "\n=== SUMMARY ===\n";
|
||||
echo 'Total brands checked: '.count($brands)."\n";
|
||||
echo "Images imported: $imported\n";
|
||||
|
||||
if (count($missingLogos) > 0) {
|
||||
echo "\nBrands missing logos (not in live database):\n";
|
||||
foreach ($missingLogos as $name) {
|
||||
echo " - $name\n";
|
||||
}
|
||||
}
|
||||
|
||||
if (count($missingBanners) > 0) {
|
||||
echo "\nBrands missing banners (not in live database):\n";
|
||||
foreach ($missingBanners as $name) {
|
||||
echo " - $name\n";
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n=== FINAL STATUS ===\n";
|
||||
$brands = Brand::where('business_id', 5)->orderBy('name')->get();
|
||||
foreach ($brands as $brand) {
|
||||
$hasLogo = $brand->logo_path && Storage::disk('public')->exists($brand->logo_path);
|
||||
$hasBanner = $brand->banner_path && Storage::disk('public')->exists($brand->banner_path);
|
||||
$logoStatus = $hasLogo ? '✓' : '✗';
|
||||
$bannerStatus = $hasBanner ? '✓' : '✗';
|
||||
echo "{$logoStatus} {$bannerStatus} {$brand->name}\n";
|
||||
}
|
||||
27
check-brand-active.php
Normal file
27
check-brand-active.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
require __DIR__.'/vendor/autoload.php';
|
||||
|
||||
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||
$app->make('Illuminate\Contracts\Console\Kernel')->bootstrap();
|
||||
|
||||
$brand = App\Models\Brand::where('slug', 'hash-factory')->first();
|
||||
|
||||
if ($brand) {
|
||||
echo "Brand: {$brand->name}\n";
|
||||
echo 'is_active: '.($brand->is_active ? 'YES' : 'NO')."\n";
|
||||
|
||||
if (! $brand->is_active) {
|
||||
echo "\n⚠️ Brand is INACTIVE - this is why 404 happens!\n";
|
||||
echo "Setting brand to active...\n";
|
||||
$brand->is_active = true;
|
||||
$brand->save();
|
||||
echo "✓ Brand is now active!\n";
|
||||
}
|
||||
}
|
||||
|
||||
$product = App\Models\Product::where('hashid', '36ck3')->first();
|
||||
if ($product) {
|
||||
echo "\nProduct: {$product->name}\n";
|
||||
echo 'is_active: '.($product->is_active ? 'YES' : 'NO')."\n";
|
||||
}
|
||||
15
check-brand-names.php
Normal file
15
check-brand-names.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
require __DIR__.'/vendor/autoload.php';
|
||||
|
||||
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
|
||||
use App\Models\Brand;
|
||||
|
||||
$brands = Brand::where('business_id', 5)->orderBy('id')->get(['id', 'name']);
|
||||
|
||||
echo "=== BRANDS IN POSTGRESQL ===\n\n";
|
||||
foreach ($brands as $b) {
|
||||
echo "ID: {$b->id}, Name: '{$b->name}'\n";
|
||||
}
|
||||
36
check-brand.php
Normal file
36
check-brand.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
require __DIR__.'/vendor/autoload.php';
|
||||
|
||||
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||
$app->make('Illuminate\Contracts\Console\Kernel')->bootstrap();
|
||||
|
||||
$brand = App\Models\Brand::where('slug', 'hash-factory')->first();
|
||||
|
||||
if ($brand) {
|
||||
echo "✓ Brand found: {$brand->name}\n";
|
||||
echo " Slug: {$brand->slug}\n";
|
||||
echo " ID: {$brand->id}\n";
|
||||
echo " Business ID: {$brand->business_id}\n";
|
||||
} else {
|
||||
echo "✗ Brand NOT found with slug 'hash-factory'\n";
|
||||
|
||||
$firstBrand = App\Models\Brand::first();
|
||||
if ($firstBrand) {
|
||||
echo "\nFirst brand in database:\n";
|
||||
echo " Name: {$firstBrand->name}\n";
|
||||
echo " Slug: {$firstBrand->slug}\n";
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
|
||||
$product = App\Models\Product::where('hashid', '36ck3')->first();
|
||||
if ($product) {
|
||||
echo "✓ Product found: {$product->name}\n";
|
||||
echo " Hashid: {$product->hashid}\n";
|
||||
echo " Brand: {$product->brand->name}\n";
|
||||
echo " Brand Slug: {$product->brand->slug}\n";
|
||||
} else {
|
||||
echo "✗ Product NOT found with hashid '36ck3'\n";
|
||||
}
|
||||
20
check-categories.php
Normal file
20
check-categories.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
echo 'Product Categories: '.\App\Models\ProductCategory::count().PHP_EOL;
|
||||
echo 'Component Categories: '.\App\Models\ComponentCategory::count().PHP_EOL;
|
||||
|
||||
echo PHP_EOL.'Sample Product Categories:'.PHP_EOL;
|
||||
\App\Models\ProductCategory::select('id', 'name', 'parent_id', 'business_id')
|
||||
->take(10)
|
||||
->get()
|
||||
->each(function ($c) {
|
||||
echo " ID: {$c->id}, Name: {$c->name}, Parent: {$c->parent_id}, Business: {$c->business_id}".PHP_EOL;
|
||||
});
|
||||
|
||||
echo PHP_EOL.'Sample Component Categories:'.PHP_EOL;
|
||||
\App\Models\ComponentCategory::select('id', 'name', 'parent_id', 'business_id')
|
||||
->take(10)
|
||||
->get()
|
||||
->each(function ($c) {
|
||||
echo " ID: {$c->id}, Name: {$c->name}, Parent: {$c->parent_id}, Business: {$c->business_id}".PHP_EOL;
|
||||
});
|
||||
13
check-columns.php
Normal file
13
check-columns.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
echo 'Checking product_categories columns:'.PHP_EOL;
|
||||
$columns = DB::select("SELECT column_name FROM information_schema.columns WHERE table_name = 'product_categories' ORDER BY ordinal_position");
|
||||
foreach ($columns as $col) {
|
||||
echo ' - '.$col->column_name.PHP_EOL;
|
||||
}
|
||||
|
||||
echo PHP_EOL.'Checking component_categories columns:'.PHP_EOL;
|
||||
$columns = DB::select("SELECT column_name FROM information_schema.columns WHERE table_name = 'component_categories' ORDER BY ordinal_position");
|
||||
foreach ($columns as $col) {
|
||||
echo ' - '.$col->column_name.PHP_EOL;
|
||||
}
|
||||
55
check-nuvata-images.php
Normal file
55
check-nuvata-images.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
$sql = file_get_contents(__DIR__.'/hubexport.sql');
|
||||
|
||||
// Parse brands INSERT statements
|
||||
if (preg_match_all('/INSERT INTO `brands` VALUES\s*\((.*?)\);/s', $sql, $inserts)) {
|
||||
foreach ($inserts[1] as $brandData) {
|
||||
// Parse fields
|
||||
$fields = [];
|
||||
$inString = false;
|
||||
$current = '';
|
||||
$parenDepth = 0;
|
||||
|
||||
for ($i = 0; $i < strlen($brandData); $i++) {
|
||||
$char = $brandData[$i];
|
||||
|
||||
if ($char === "'" && ($i === 0 || $brandData[$i - 1] !== '\\')) {
|
||||
$inString = ! $inString;
|
||||
$current .= $char;
|
||||
} elseif (! $inString && $char === ',' && $parenDepth === 0) {
|
||||
$fields[] = trim($current);
|
||||
$current = '';
|
||||
} else {
|
||||
if ($char === '(' && ! $inString) {
|
||||
$parenDepth++;
|
||||
}
|
||||
if ($char === ')' && ! $inString) {
|
||||
$parenDepth--;
|
||||
}
|
||||
$current .= $char;
|
||||
}
|
||||
}
|
||||
if ($current !== '') {
|
||||
$fields[] = trim($current);
|
||||
}
|
||||
|
||||
// Extract brand name
|
||||
$name = isset($fields[1]) ? trim($fields[1], "'") : '';
|
||||
|
||||
// Only process Nuvata
|
||||
if ($name !== 'Nuvata') {
|
||||
continue;
|
||||
}
|
||||
|
||||
echo "Nuvata Image Data:\n\n";
|
||||
|
||||
// Field 4 = logo image
|
||||
$imageBlob = isset($fields[4]) ? $fields[4] : 'NOT SET';
|
||||
echo 'Logo (field 4): '.($imageBlob === 'NULL' ? 'NULL' : substr($imageBlob, 0, 100).'... (length: '.strlen($imageBlob).')')."\n\n";
|
||||
|
||||
// Field 17 = banner image
|
||||
$bannerBlob = isset($fields[17]) ? $fields[17] : 'NOT SET';
|
||||
echo 'Banner (field 17): '.($bannerBlob === 'NULL' ? 'NULL' : substr($bannerBlob, 0, 100).'... (length: '.strlen($bannerBlob).')')."\n\n";
|
||||
}
|
||||
}
|
||||
64
check-nuvata-products.php
Normal file
64
check-nuvata-products.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
$sql = file_get_contents(__DIR__.'/hubexport.sql');
|
||||
|
||||
echo "=== CHECKING NUVATA PRODUCTS IN OLD MYSQL ===\n\n";
|
||||
|
||||
// Find product INSERTs
|
||||
if (preg_match_all('/INSERT INTO `products` VALUES\s*\((.*?)\);/s', $sql, $inserts)) {
|
||||
echo 'Total product records: '.count($inserts[1])."\n\n";
|
||||
|
||||
$nuvataCount = 0;
|
||||
$nuvataProducts = [];
|
||||
|
||||
foreach ($inserts[1] as $productData) {
|
||||
// Parse fields
|
||||
$fields = [];
|
||||
$inString = false;
|
||||
$current = '';
|
||||
$parenDepth = 0;
|
||||
|
||||
for ($i = 0; $i < strlen($productData); $i++) {
|
||||
$char = $productData[$i];
|
||||
|
||||
if ($char === "'" && ($i === 0 || $productData[$i - 1] !== '\\')) {
|
||||
$inString = ! $inString;
|
||||
$current .= $char;
|
||||
} elseif (! $inString && $char === ',' && $parenDepth === 0) {
|
||||
$fields[] = trim($current);
|
||||
$current = '';
|
||||
} else {
|
||||
if ($char === '(' && ! $inString) {
|
||||
$parenDepth++;
|
||||
}
|
||||
if ($char === ')' && ! $inString) {
|
||||
$parenDepth--;
|
||||
}
|
||||
$current .= $char;
|
||||
}
|
||||
}
|
||||
if ($current !== '') {
|
||||
$fields[] = trim($current);
|
||||
}
|
||||
|
||||
// Field 1 = brand_id (in old MySQL)
|
||||
// Field 2 = name
|
||||
$brandId = isset($fields[1]) ? $fields[1] : '';
|
||||
$name = isset($fields[2]) ? trim($fields[2], "'") : '';
|
||||
|
||||
// Nuvata brand_id in old MySQL is 5
|
||||
if ($brandId === '5') {
|
||||
$nuvataCount++;
|
||||
$nuvataProducts[] = $name;
|
||||
}
|
||||
}
|
||||
|
||||
echo "Nuvata products found: $nuvataCount\n\n";
|
||||
|
||||
if ($nuvataCount > 0) {
|
||||
echo "Product names:\n";
|
||||
foreach ($nuvataProducts as $product) {
|
||||
echo " - $product\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
56
check-nuvata-state.php
Normal file
56
check-nuvata-state.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
$sql = file_get_contents(__DIR__.'/hubexport.sql');
|
||||
|
||||
// Parse brands INSERT statements
|
||||
if (preg_match_all('/INSERT INTO `brands` VALUES\s*\((.*?)\);/s', $sql, $inserts)) {
|
||||
foreach ($inserts[1] as $brandData) {
|
||||
// Parse fields
|
||||
$fields = [];
|
||||
$inString = false;
|
||||
$current = '';
|
||||
$parenDepth = 0;
|
||||
|
||||
for ($i = 0; $i < strlen($brandData); $i++) {
|
||||
$char = $brandData[$i];
|
||||
|
||||
if ($char === "'" && ($i === 0 || $brandData[$i - 1] !== '\\')) {
|
||||
$inString = ! $inString;
|
||||
$current .= $char;
|
||||
} elseif (! $inString && $char === ',' && $parenDepth === 0) {
|
||||
$fields[] = trim($current);
|
||||
$current = '';
|
||||
} else {
|
||||
if ($char === '(' && ! $inString) {
|
||||
$parenDepth++;
|
||||
}
|
||||
if ($char === ')' && ! $inString) {
|
||||
$parenDepth--;
|
||||
}
|
||||
$current .= $char;
|
||||
}
|
||||
}
|
||||
if ($current !== '') {
|
||||
$fields[] = trim($current);
|
||||
}
|
||||
|
||||
// Extract brand name
|
||||
$name = isset($fields[1]) ? trim($fields[1], "'") : '';
|
||||
|
||||
// Only process Nuvata
|
||||
if ($name !== 'Nuvata') {
|
||||
continue;
|
||||
}
|
||||
|
||||
echo "Nuvata Brand Data:\n";
|
||||
echo 'State (field 14): '.(isset($fields[14]) ? $fields[14] : 'NOT SET')."\n";
|
||||
echo 'State value: '.(isset($fields[14]) ? trim($fields[14], "'") : 'NULL')."\n";
|
||||
echo 'State length: '.(isset($fields[14]) ? strlen(trim($fields[14], "'")) : 0)."\n";
|
||||
|
||||
// Show surrounding fields for context
|
||||
echo "\nContext:\n";
|
||||
echo 'City (field 13): '.(isset($fields[13]) ? trim($fields[13], "'") : 'NULL')."\n";
|
||||
echo 'Zip (field 12): '.(isset($fields[12]) ? trim($fields[12], "'") : 'NULL')."\n";
|
||||
echo 'Phone (field 15): '.(isset($fields[15]) ? trim($fields[15], "'") : 'NULL')."\n";
|
||||
}
|
||||
}
|
||||
22
check-product-columns.php
Normal file
22
check-product-columns.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
$host = 'sql1.creationshop.net';
|
||||
$username = 'claude';
|
||||
$password = 'claude';
|
||||
$database = 'hub_cannabrands';
|
||||
|
||||
try {
|
||||
$conn = new PDO("mysql:host=$host;dbname=$database", $username, $password);
|
||||
$conn->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
|
||||
echo "=== PRODUCTS TABLE COLUMNS ===\n\n";
|
||||
$stmt = $conn->query('DESCRIBE products');
|
||||
$columns = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
foreach ($columns as $col) {
|
||||
echo " {$col['Field']} ({$col['Type']})\n";
|
||||
}
|
||||
|
||||
} catch (PDOException $e) {
|
||||
echo 'ERROR: '.$e->getMessage()."\n";
|
||||
}
|
||||
35
check-product-images-schema.php
Normal file
35
check-product-images-schema.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
$host = 'sql1.creationshop.net';
|
||||
$username = 'claude';
|
||||
$password = 'claude';
|
||||
$database = 'hub_cannabrands';
|
||||
|
||||
try {
|
||||
$conn = new PDO("mysql:host=$host;dbname=$database", $username, $password);
|
||||
$conn->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
|
||||
echo "=== PRODUCT_IMAGES TABLE SCHEMA ===\n\n";
|
||||
$stmt = $conn->query('DESCRIBE product_images');
|
||||
$columns = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
foreach ($columns as $col) {
|
||||
echo " {$col['Field']} ({$col['Type']})\n";
|
||||
}
|
||||
|
||||
echo "\n\n=== SAMPLE ROW ===\n";
|
||||
$stmt = $conn->query('SELECT * FROM product_images LIMIT 1');
|
||||
$sample = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($sample) {
|
||||
foreach ($sample as $key => $value) {
|
||||
if (is_string($value) && strlen($value) > 100) {
|
||||
$value = substr($value, 0, 100).'... ['.strlen($value).' bytes]';
|
||||
}
|
||||
echo " $key: $value\n";
|
||||
}
|
||||
}
|
||||
|
||||
} catch (PDOException $e) {
|
||||
echo 'ERROR: '.$e->getMessage()."\n";
|
||||
}
|
||||
35
check_all_brands.php
Normal file
35
check_all_brands.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
require __DIR__.'/vendor/autoload.php';
|
||||
|
||||
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||
$app->make('Illuminate\Contracts\Console\Kernel')->bootstrap();
|
||||
|
||||
use App\Models\Brand;
|
||||
|
||||
echo "All brands in the database:\n\n";
|
||||
|
||||
$brands = Brand::with('business')->get();
|
||||
|
||||
if ($brands->isEmpty()) {
|
||||
echo "No brands found in database\n";
|
||||
} else {
|
||||
foreach ($brands as $brand) {
|
||||
echo "Brand ID: {$brand->id}\n";
|
||||
echo "Brand Name: {$brand->name}\n";
|
||||
echo "Business ID: {$brand->business_id}\n";
|
||||
echo 'Business Name: '.($brand->business ? $brand->business->name : 'N/A')."\n";
|
||||
echo 'Active: '.($brand->is_active ? 'Yes' : 'No')."\n";
|
||||
echo "---\n";
|
||||
}
|
||||
}
|
||||
|
||||
echo "\nCannabrands business (ID: 5) brands:\n";
|
||||
$cannabrandsBrands = Brand::where('business_id', 5)->get();
|
||||
if ($cannabrandsBrands->isEmpty()) {
|
||||
echo "No brands found for Cannabrands\n";
|
||||
} else {
|
||||
foreach ($cannabrandsBrands as $brand) {
|
||||
echo "- {$brand->name} (ID: {$brand->id})\n";
|
||||
}
|
||||
}
|
||||
26
check_cannabrands.php
Normal file
26
check_cannabrands.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
require __DIR__.'/vendor/autoload.php';
|
||||
|
||||
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||
$app->make('Illuminate\Contracts\Console\Kernel')->bootstrap();
|
||||
|
||||
$businesses = App\Models\Business::where('name', 'LIKE', '%cannabrand%')
|
||||
->orWhere('slug', 'LIKE', '%cannabrand%')
|
||||
->get();
|
||||
|
||||
if ($businesses->count() > 0) {
|
||||
echo "Found {$businesses->count()} business(es) matching Cannabrands:\n";
|
||||
foreach ($businesses as $business) {
|
||||
echo "ID: {$business->id} | Name: {$business->name} | Slug: {$business->slug} | Type: {$business->type}\n";
|
||||
}
|
||||
} else {
|
||||
echo "No Cannabrands business found in database\n";
|
||||
|
||||
// Also list all businesses
|
||||
echo "\nAll businesses in database:\n";
|
||||
$allBusinesses = App\Models\Business::all();
|
||||
foreach ($allBusinesses as $business) {
|
||||
echo "ID: {$business->id} | Name: {$business->name} | Slug: {$business->slug}\n";
|
||||
}
|
||||
}
|
||||
30
check_hash_factory.php
Normal file
30
check_hash_factory.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
require __DIR__.'/vendor/autoload.php';
|
||||
|
||||
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||
$app->make('Illuminate\Contracts\Console\Kernel')->bootstrap();
|
||||
|
||||
$brand = App\Models\Brand::find(4);
|
||||
|
||||
if ($brand) {
|
||||
echo "Hash Factory brand found:\n";
|
||||
echo "ID: {$brand->id}\n";
|
||||
echo "Name: {$brand->name}\n";
|
||||
echo "Business ID: {$brand->business_id}\n";
|
||||
|
||||
if ($brand->business) {
|
||||
echo "Business Name: {$brand->business->name}\n";
|
||||
echo "Business Slug: {$brand->business->slug}\n";
|
||||
}
|
||||
} else {
|
||||
echo "Hash Factory brand (ID: 4) not found\n";
|
||||
}
|
||||
|
||||
// Check for business_user pivot entry
|
||||
echo "\n\nChecking business_user associations for Cannabrands (ID: 5):\n";
|
||||
$pivotEntries = DB::table('business_user')->where('business_id', 5)->get();
|
||||
foreach ($pivotEntries as $entry) {
|
||||
$user = App\Models\User::find($entry->user_id);
|
||||
echo "User ID: {$entry->user_id} | Email: {$user->email} | Is Primary: {$entry->is_primary}\n";
|
||||
}
|
||||
@@ -42,7 +42,8 @@
|
||||
"Database\\Seeders\\": "database/seeders/"
|
||||
},
|
||||
"files": [
|
||||
"app/helpers.php"
|
||||
"app/helpers.php",
|
||||
"app/Helpers/helpers.php"
|
||||
]
|
||||
},
|
||||
"autoload-dev": {
|
||||
|
||||
@@ -60,6 +60,18 @@ return [
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
'minio' => [
|
||||
'driver' => 's3',
|
||||
'key' => env('MINIO_ACCESS_KEY'),
|
||||
'secret' => env('MINIO_SECRET_KEY'),
|
||||
'region' => env('MINIO_REGION', 'us-east-1'),
|
||||
'bucket' => env('MINIO_BUCKET'),
|
||||
'endpoint' => env('MINIO_ENDPOINT'),
|
||||
'use_path_style_endpoint' => true,
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|
||||
321
config/permissions.php
Normal file
321
config/permissions.php
Normal file
@@ -0,0 +1,321 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Permission Categories & Definitions
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| All available permissions organized by category for the permissions UI.
|
||||
| Each category has a name, icon, and list of permissions.
|
||||
|
|
||||
*/
|
||||
'categories' => [
|
||||
'analytics' => [
|
||||
'name' => 'Analytics',
|
||||
'icon' => 'lucide--bar-chart-3',
|
||||
'description' => 'View analytics and reports',
|
||||
'permissions' => [
|
||||
'analytics.overview' => [
|
||||
'name' => 'View analytics overview',
|
||||
'description' => 'Access dashboard and KPIs',
|
||||
],
|
||||
'analytics.products' => [
|
||||
'name' => 'View product analytics',
|
||||
'description' => 'Product performance metrics',
|
||||
],
|
||||
'analytics.marketing' => [
|
||||
'name' => 'View marketing analytics',
|
||||
'description' => 'Email campaigns and engagement',
|
||||
],
|
||||
'analytics.sales' => [
|
||||
'name' => 'View sales analytics',
|
||||
'description' => 'Sales funnel and conversion',
|
||||
],
|
||||
'analytics.buyers' => [
|
||||
'name' => 'View buyer intelligence',
|
||||
'description' => 'Buyer engagement scores',
|
||||
],
|
||||
'analytics.export' => [
|
||||
'name' => 'Export analytics data',
|
||||
'description' => 'Download reports and data',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'products' => [
|
||||
'name' => 'Products',
|
||||
'icon' => 'lucide--package',
|
||||
'description' => 'Manage product catalog',
|
||||
'permissions' => [
|
||||
'products.view' => [
|
||||
'name' => 'View products',
|
||||
'description' => 'Browse product catalog',
|
||||
],
|
||||
'products.create' => [
|
||||
'name' => 'Create products',
|
||||
'description' => 'Add new products',
|
||||
],
|
||||
'products.edit' => [
|
||||
'name' => 'Edit products',
|
||||
'description' => 'Update product details',
|
||||
],
|
||||
'products.delete' => [
|
||||
'name' => 'Delete products',
|
||||
'description' => 'Archive or remove products',
|
||||
],
|
||||
'products.pricing' => [
|
||||
'name' => 'Manage pricing',
|
||||
'description' => 'Set and update prices',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'orders' => [
|
||||
'name' => 'Orders',
|
||||
'icon' => 'lucide--shopping-cart',
|
||||
'description' => 'Manage orders and fulfillment',
|
||||
'permissions' => [
|
||||
'orders.view' => [
|
||||
'name' => 'View orders',
|
||||
'description' => 'See order history',
|
||||
],
|
||||
'orders.create' => [
|
||||
'name' => 'Create orders',
|
||||
'description' => 'Create new orders',
|
||||
],
|
||||
'orders.edit' => [
|
||||
'name' => 'Edit orders',
|
||||
'description' => 'Update order details',
|
||||
],
|
||||
'orders.cancel' => [
|
||||
'name' => 'Cancel orders',
|
||||
'description' => 'Cancel pending orders',
|
||||
],
|
||||
'orders.fulfill' => [
|
||||
'name' => 'Fulfill orders',
|
||||
'description' => 'Mark orders as fulfilled',
|
||||
],
|
||||
'orders.ship' => [
|
||||
'name' => 'Ship orders',
|
||||
'description' => 'Create shipments',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'customers' => [
|
||||
'name' => 'Customers',
|
||||
'icon' => 'lucide--users',
|
||||
'description' => 'Manage customer relationships',
|
||||
'permissions' => [
|
||||
'customers.view' => [
|
||||
'name' => 'View customers',
|
||||
'description' => 'See customer list',
|
||||
],
|
||||
'customers.create' => [
|
||||
'name' => 'Create customers',
|
||||
'description' => 'Add new customers',
|
||||
],
|
||||
'customers.edit' => [
|
||||
'name' => 'Edit customers',
|
||||
'description' => 'Update customer info',
|
||||
],
|
||||
'customers.delete' => [
|
||||
'name' => 'Delete customers',
|
||||
'description' => 'Remove customers',
|
||||
],
|
||||
'customers.contact' => [
|
||||
'name' => 'Contact customers',
|
||||
'description' => 'Send emails and messages',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'financial' => [
|
||||
'name' => 'Financial',
|
||||
'icon' => 'lucide--dollar-sign',
|
||||
'description' => 'Manage invoices and payments',
|
||||
'permissions' => [
|
||||
'invoices.view' => [
|
||||
'name' => 'View invoices',
|
||||
'description' => 'See invoice history',
|
||||
],
|
||||
'invoices.create' => [
|
||||
'name' => 'Create invoices',
|
||||
'description' => 'Generate new invoices',
|
||||
],
|
||||
'invoices.edit' => [
|
||||
'name' => 'Edit invoices',
|
||||
'description' => 'Update invoice details',
|
||||
],
|
||||
'invoices.void' => [
|
||||
'name' => 'Void invoices',
|
||||
'description' => 'Cancel/void invoices',
|
||||
],
|
||||
'payments.view' => [
|
||||
'name' => 'View payments',
|
||||
'description' => 'See payment history',
|
||||
],
|
||||
'payments.process' => [
|
||||
'name' => 'Process payments',
|
||||
'description' => 'Accept and record payments',
|
||||
],
|
||||
'payments.refund' => [
|
||||
'name' => 'Refund payments',
|
||||
'description' => 'Issue refunds',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'users' => [
|
||||
'name' => 'Users & Settings',
|
||||
'icon' => 'lucide--user-cog',
|
||||
'description' => 'Manage team and settings',
|
||||
'permissions' => [
|
||||
'users.view' => [
|
||||
'name' => 'View users',
|
||||
'description' => 'See team members',
|
||||
],
|
||||
'users.edit' => [
|
||||
'name' => 'Edit users',
|
||||
'description' => 'Update user info',
|
||||
],
|
||||
'users.permissions' => [
|
||||
'name' => 'Manage permissions',
|
||||
'description' => 'Grant/revoke permissions',
|
||||
],
|
||||
'users.view_as' => [
|
||||
'name' => 'View as other users',
|
||||
'description' => 'Impersonate team members',
|
||||
],
|
||||
'settings.edit' => [
|
||||
'name' => 'Edit settings',
|
||||
'description' => 'Update business settings',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Role Templates (Presets)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Pre-defined roles with common permission sets.
|
||||
| Users can select these as a starting point and customize.
|
||||
|
|
||||
*/
|
||||
'role_templates' => [
|
||||
'sales_rep' => [
|
||||
'name' => 'Sales Representative',
|
||||
'description' => 'Manage orders and customer relationships',
|
||||
'permissions' => [
|
||||
'analytics.overview',
|
||||
'analytics.sales',
|
||||
'products.view',
|
||||
'orders.view',
|
||||
'orders.create',
|
||||
'orders.edit',
|
||||
'orders.fulfill',
|
||||
'orders.ship',
|
||||
'customers.view',
|
||||
'customers.create',
|
||||
'customers.edit',
|
||||
'customers.contact',
|
||||
],
|
||||
],
|
||||
|
||||
'accountant' => [
|
||||
'name' => 'Accountant',
|
||||
'description' => 'Manage financial operations and reporting',
|
||||
'permissions' => [
|
||||
'analytics.overview',
|
||||
'analytics.sales',
|
||||
'orders.view',
|
||||
'customers.view',
|
||||
'invoices.view',
|
||||
'invoices.create',
|
||||
'invoices.edit',
|
||||
'invoices.void',
|
||||
'payments.view',
|
||||
'payments.process',
|
||||
'payments.refund',
|
||||
],
|
||||
],
|
||||
|
||||
'inventory_manager' => [
|
||||
'name' => 'Inventory Manager',
|
||||
'description' => 'Manage products and fulfill orders',
|
||||
'permissions' => [
|
||||
'analytics.overview',
|
||||
'analytics.products',
|
||||
'products.view',
|
||||
'products.create',
|
||||
'products.edit',
|
||||
'products.delete',
|
||||
'products.pricing',
|
||||
'orders.view',
|
||||
'orders.fulfill',
|
||||
],
|
||||
],
|
||||
|
||||
'marketing_manager' => [
|
||||
'name' => 'Marketing Manager',
|
||||
'description' => 'Manage marketing, analytics, and customer engagement',
|
||||
'permissions' => [
|
||||
'analytics.overview',
|
||||
'analytics.products',
|
||||
'analytics.marketing',
|
||||
'analytics.buyers',
|
||||
'analytics.export',
|
||||
'products.view',
|
||||
'products.edit',
|
||||
'customers.view',
|
||||
'customers.contact',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Audit Trail Settings
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
'audit' => [
|
||||
// Default retention period (in days) for non-critical audit logs
|
||||
'retention_days' => env('AUDIT_RETENTION_DAYS', 90),
|
||||
|
||||
// Permissions that should be flagged as critical (kept forever)
|
||||
// These relate to security-sensitive operations
|
||||
'critical_permissions' => [
|
||||
'users.permissions', // Managing permissions
|
||||
'users.view_as', // Impersonation capability
|
||||
'invoices.void', // Voiding invoices
|
||||
'payments.refund', // Refunding payments
|
||||
'payments.process', // Processing payments
|
||||
],
|
||||
|
||||
// Actions that should always be flagged as critical
|
||||
'critical_actions' => [
|
||||
'role_changed', // Any role template changes
|
||||
'granted_critical', // Granting critical permissions
|
||||
'revoked_critical', // Revoking critical permissions
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| View As Settings
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
'view_as' => [
|
||||
// Session timeout (in minutes)
|
||||
'timeout_minutes' => env('VIEW_AS_TIMEOUT', 60),
|
||||
|
||||
// Maximum concurrent sessions per user
|
||||
'max_concurrent_sessions' => 3,
|
||||
|
||||
// Track pages accessed during View As sessions
|
||||
'track_pages' => true,
|
||||
],
|
||||
];
|
||||
32
count-products-by-brand.php
Normal file
32
count-products-by-brand.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
$sql = file_get_contents(__DIR__.'/hubexport.sql');
|
||||
|
||||
echo "=== PRODUCTS PER BRAND ===\n\n";
|
||||
|
||||
if (preg_match_all('/INSERT INTO `products` VALUES\s*\((.*?)\);/s', $sql, $inserts)) {
|
||||
$brandCounts = [];
|
||||
|
||||
foreach ($inserts[1] as $data) {
|
||||
// Quick parse to get brand_id (field 1)
|
||||
$parts = explode(',', substr($data, 0, 50));
|
||||
$brandId = trim($parts[1] ?? '');
|
||||
$brandCounts[$brandId] = ($brandCounts[$brandId] ?? 0) + 1;
|
||||
}
|
||||
|
||||
$brandNames = [
|
||||
'1' => 'Hash Factory',
|
||||
'11' => 'Aloha TymeMachine',
|
||||
'12' => "Blitz'd",
|
||||
'14' => 'Outlaw Cannabis',
|
||||
'15' => 'Nuvata',
|
||||
];
|
||||
|
||||
ksort($brandCounts);
|
||||
foreach ($brandCounts as $brandId => $count) {
|
||||
$name = $brandNames[$brandId] ?? 'Unknown Brand';
|
||||
echo "Brand $brandId ($name): $count products\n";
|
||||
}
|
||||
|
||||
echo "\nTotal: ".array_sum($brandCounts)." products\n";
|
||||
}
|
||||
65
create_cannabrands_user.php
Normal file
65
create_cannabrands_user.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
require __DIR__.'/vendor/autoload.php';
|
||||
|
||||
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||
$app->make('Illuminate\Contracts\Console\Kernel')->bootstrap();
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
// Find Cannabrands business
|
||||
$cannabrands = Business::find(5);
|
||||
|
||||
if (! $cannabrands) {
|
||||
echo "Error: Cannabrands business (ID: 5) not found\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "Found Cannabrands business: {$cannabrands->name} (ID: {$cannabrands->id})\n";
|
||||
|
||||
// Check if user already exists
|
||||
$existingUser = User::where('email', 'cannabrands@example.com')->first();
|
||||
|
||||
if ($existingUser) {
|
||||
echo "User cannabrands@example.com already exists (ID: {$existingUser->id})\n";
|
||||
|
||||
// Check if already associated with Cannabrands
|
||||
if ($existingUser->businesses->contains($cannabrands->id)) {
|
||||
echo "User is already associated with Cannabrands business\n";
|
||||
} else {
|
||||
// Associate with Cannabrands
|
||||
$existingUser->businesses()->attach($cannabrands->id, [
|
||||
'is_primary' => true,
|
||||
'contact_type' => 'owner',
|
||||
'role' => 'owner',
|
||||
]);
|
||||
echo "Associated user with Cannabrands business\n";
|
||||
}
|
||||
} else {
|
||||
// Create new user
|
||||
$user = User::create([
|
||||
'first_name' => 'Cannabrands',
|
||||
'last_name' => 'Admin',
|
||||
'email' => 'cannabrands@example.com',
|
||||
'password' => Hash::make('password'),
|
||||
'user_type' => 'seller',
|
||||
'status' => 'active',
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
|
||||
echo "Created new user: {$user->email} (ID: {$user->id})\n";
|
||||
|
||||
// Associate with Cannabrands business
|
||||
$user->businesses()->attach($cannabrands->id, [
|
||||
'is_primary' => true,
|
||||
'contact_type' => 'owner',
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
echo "Associated user with Cannabrands business\n";
|
||||
}
|
||||
|
||||
echo "\nDone! You can now impersonate cannabrands@example.com\n";
|
||||
echo "Login credentials: cannabrands@example.com / password\n";
|
||||
43
database/factories/ComponentCategoryFactory.php
Normal file
43
database/factories/ComponentCategoryFactory.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Models\ComponentCategory;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ComponentCategoryFactory extends Factory
|
||||
{
|
||||
protected $model = ComponentCategory::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
$name = fake()->words(2, true);
|
||||
|
||||
return [
|
||||
'business_id' => Business::factory(),
|
||||
'name' => $name,
|
||||
'slug' => Str::slug($name),
|
||||
'description' => fake()->sentence(),
|
||||
'sort_order' => 0,
|
||||
'parent_id' => null,
|
||||
'is_active' => true,
|
||||
];
|
||||
}
|
||||
|
||||
public function inactive(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'is_active' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
public function withParent(ComponentCategory $parent): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'parent_id' => $parent->id,
|
||||
'business_id' => $parent->business_id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
56
database/factories/ComponentFactory.php
Normal file
56
database/factories/ComponentFactory.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Models\Component;
|
||||
use App\Models\ComponentCategory;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
class ComponentFactory extends Factory
|
||||
{
|
||||
protected $model = Component::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'business_id' => Business::factory(),
|
||||
'category_id' => ComponentCategory::factory(),
|
||||
'name' => fake()->words(3, true),
|
||||
'description' => fake()->sentence(),
|
||||
'type' => fake()->randomElement(['raw_material', 'packaging', 'supply']),
|
||||
'cost_per_unit' => fake()->randomFloat(2, 0.50, 100.00),
|
||||
'unit_of_measure' => fake()->randomElement(['unit', 'lb', 'oz', 'g', 'kg']),
|
||||
'quantity_on_hand' => fake()->numberBetween(0, 1000),
|
||||
'reorder_point' => fake()->numberBetween(10, 50),
|
||||
'reorder_quantity' => fake()->numberBetween(50, 200),
|
||||
'vendor_name' => fake()->company(),
|
||||
'vendor_sku' => 'VND-'.strtoupper(fake()->bothify('??####')),
|
||||
'lead_time_days' => fake()->randomFloat(1, 1, 30),
|
||||
'is_active' => true,
|
||||
'is_sellable' => fake()->boolean(30),
|
||||
];
|
||||
}
|
||||
|
||||
public function inactive(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'is_active' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
public function sellable(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'is_sellable' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function lowStock(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'quantity_on_hand' => fake()->numberBetween(0, 10),
|
||||
'reorder_point' => 15,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -7,56 +7,115 @@ use Illuminate\Support\Facades\Schema;
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
* DEFINITIVE BATCH SYSTEM - See BATCH_AND_LAB_SYSTEM.md
|
||||
*
|
||||
* Batches represent distinct production runs of a product.
|
||||
* Each batch has its own lab tests (COAs), inventory levels, and traceability.
|
||||
* This is required for cannabis compliance and allows buyers to select specific batches.
|
||||
* Batches are tested buckets of finite material.
|
||||
* Two types:
|
||||
* 1. Component batches: Tested input material (flower, rosin, etc.)
|
||||
* 2. Homogenized batches: Mixed components requiring new testing
|
||||
*
|
||||
* Batches link to SKU variants (NOT parent products) via product_batches pivot table.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('batches', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
// Product relationship
|
||||
$table->foreignId('product_id')
|
||||
->constrained('products')
|
||||
// Multi-tenancy
|
||||
$table->foreignId('business_id')
|
||||
->constrained('businesses')
|
||||
->onDelete('cascade')
|
||||
->comment('The product this batch belongs to');
|
||||
->comment('Business that owns this batch');
|
||||
|
||||
// Batch Type: component or homogenized
|
||||
$table->enum('batch_type', ['component', 'homogenized'])
|
||||
->comment('component = tested input material, homogenized = mixed components');
|
||||
|
||||
// Component batch specific (null if homogenized)
|
||||
$table->foreignId('component_id')
|
||||
->nullable()
|
||||
->constrained('components')
|
||||
->onDelete('set null')
|
||||
->comment('For component batches: links to the component');
|
||||
|
||||
// Batch identification
|
||||
$table->string('batch_number')->unique()->comment('Unique batch identifier (e.g., 240315-001, TB-AM-240315)');
|
||||
$table->string('internal_code')->nullable()->comment('Internal production/tracking code');
|
||||
$table->string('batch_number')
|
||||
->unique()
|
||||
->comment('Unique batch identifier (e.g., CB-240315-001)');
|
||||
|
||||
// Production dates
|
||||
$table->date('production_date')->nullable()->comment('Date the batch was produced/manufactured');
|
||||
$table->date('harvest_date')->nullable()->comment('Harvest date (for flower products)');
|
||||
$table->date('package_date')->nullable()->comment('Date the batch was packaged');
|
||||
$table->date('expiration_date')->nullable()->comment('Expiration/best-by date');
|
||||
$table->string('internal_code')
|
||||
->nullable()
|
||||
->comment('Internal production/tracking code');
|
||||
|
||||
// Inventory quantities
|
||||
$table->integer('quantity_produced')->default(0)->comment('Total units produced in this batch');
|
||||
$table->integer('quantity_available')->default(0)->comment('Units currently available for sale');
|
||||
$table->integer('quantity_allocated')->default(0)->comment('Units reserved in pending orders');
|
||||
$table->integer('quantity_sold')->default(0)->comment('Units already sold');
|
||||
// Inventory - finite quantity tracking
|
||||
$table->integer('quantity_total')
|
||||
->comment('Total quantity produced in this batch');
|
||||
|
||||
// Status and availability
|
||||
$table->boolean('is_active')->default(true)->comment('Is this batch available for sale?');
|
||||
$table->boolean('is_tested')->default(false)->comment('Has this batch passed lab testing?');
|
||||
$table->boolean('is_quarantined')->default(false)->comment('Is this batch quarantined pending results?');
|
||||
$table->integer('quantity_remaining')
|
||||
->comment('Quantity still available (depletes as SKUs use it)');
|
||||
|
||||
$table->string('quantity_unit', 20)
|
||||
->nullable()
|
||||
->comment('lbs, g, kg, units, etc.');
|
||||
|
||||
// Primary COA/Lab for this batch
|
||||
$table->foreignId('primary_coa_id')
|
||||
->nullable()
|
||||
->constrained('labs')
|
||||
->onDelete('set null')
|
||||
->comment('Primary lab test (COA) for this batch');
|
||||
|
||||
// Dates
|
||||
$table->date('production_date')
|
||||
->nullable()
|
||||
->comment('Date batch was produced/manufactured');
|
||||
|
||||
$table->date('test_date')
|
||||
->nullable()
|
||||
->comment('Date batch was tested');
|
||||
|
||||
$table->date('expiration_date')
|
||||
->nullable()
|
||||
->comment('Expiration/best-by date');
|
||||
|
||||
// Status flags
|
||||
$table->boolean('is_active')
|
||||
->default(true)
|
||||
->comment('Is this batch available for use?');
|
||||
|
||||
$table->boolean('is_tested')
|
||||
->default(false)
|
||||
->comment('Has this batch passed lab testing?');
|
||||
|
||||
$table->boolean('is_quarantined')
|
||||
->default(false)
|
||||
->comment('Is this batch quarantined pending results?');
|
||||
|
||||
// QR Code
|
||||
$table->string('qr_code_path')
|
||||
->nullable()
|
||||
->comment('Path to generated QR code file');
|
||||
|
||||
// Additional information
|
||||
$table->text('notes')->nullable()->comment('Production notes, special handling instructions, etc.');
|
||||
$table->json('metadata')->nullable()->comment('Additional batch data (growing conditions, etc.)');
|
||||
$table->text('notes')
|
||||
->nullable()
|
||||
->comment('Production notes, special handling instructions, etc.');
|
||||
|
||||
$table->json('metadata')
|
||||
->nullable()
|
||||
->comment('Additional batch data (growing conditions, etc.)');
|
||||
|
||||
// Timestamps
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
// Indexes
|
||||
$table->index('product_id');
|
||||
$table->index('business_id');
|
||||
$table->index('batch_type');
|
||||
$table->index('component_id');
|
||||
$table->index('batch_number');
|
||||
$table->index(['product_id', 'is_active']);
|
||||
$table->index('primary_coa_id');
|
||||
$table->index(['business_id', 'batch_type', 'is_active']);
|
||||
$table->index('production_date');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Tracks which component batches were used to create homogenized product batches
|
||||
*
|
||||
* Example: Enhanced preroll (homogenized batch) uses:
|
||||
* - Component Batch #123: Flower (10 lbs)
|
||||
* - Component Batch #456: Rosin (2 lbs)
|
||||
*
|
||||
* This enables full traceability and allows QR codes to show all source COAs.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('batch_source_components', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
// The homogenized (mixed) product batch
|
||||
$table->foreignId('homogenized_batch_id')
|
||||
->constrained('batches')
|
||||
->onDelete('cascade')
|
||||
->comment('The batch that was created by mixing');
|
||||
|
||||
// The component batch that was used
|
||||
$table->foreignId('source_batch_id')
|
||||
->constrained('batches')
|
||||
->onDelete('cascade')
|
||||
->comment('The component batch used in the mix');
|
||||
|
||||
// How much was used
|
||||
$table->decimal('quantity_used', 10, 2)
|
||||
->comment('Amount of component consumed');
|
||||
|
||||
$table->string('unit', 20)
|
||||
->comment('lbs, g, kg, units, etc.');
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
// Indexes
|
||||
$table->index('homogenized_batch_id');
|
||||
$table->index('source_batch_id');
|
||||
|
||||
// Prevent duplicate entries
|
||||
$table->unique(['homogenized_batch_id', 'source_batch_id']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('batch_source_components');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Links SKU variants to batches
|
||||
*
|
||||
* This is the CRITICAL pivot table that connects:
|
||||
* - product_id: The SKU variant (NOT parent product)
|
||||
* - batch_id: The batch (component or homogenized)
|
||||
*
|
||||
* Multiple SKUs can pull from the same batch.
|
||||
* Example: 1/8oz, 1/4oz, 1oz jars all use same flower batch
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('product_batches', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
// The SKU variant (NOT parent product with has_varieties=true)
|
||||
$table->foreignId('product_id')
|
||||
->constrained('products')
|
||||
->onDelete('cascade')
|
||||
->comment('SKU variant that uses this batch');
|
||||
|
||||
// The batch being used
|
||||
$table->foreignId('batch_id')
|
||||
->constrained('batches')
|
||||
->onDelete('cascade')
|
||||
->comment('Batch allocated to this SKU');
|
||||
|
||||
// How much of the batch is allocated to this SKU
|
||||
$table->integer('quantity_allocated')
|
||||
->default(0)
|
||||
->comment('Units of batch reserved for this SKU');
|
||||
|
||||
// Active status
|
||||
$table->boolean('is_active')
|
||||
->default(true)
|
||||
->comment('Is this batch currently active for this SKU?');
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
// Indexes
|
||||
$table->index('product_id');
|
||||
$table->index('batch_id');
|
||||
$table->index(['product_id', 'is_active']);
|
||||
$table->index(['batch_id', 'is_active']);
|
||||
|
||||
// A product can have multiple batches (when one depletes, add new one)
|
||||
// But prevent exact duplicates
|
||||
$table->unique(['product_id', 'batch_id']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('product_batches');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Add batch_id to labs table
|
||||
*
|
||||
* Links COAs (labs) to specific batches.
|
||||
* Each batch has one primary COA, but we keep this relationship
|
||||
* for easier querying (labs.batch_id).
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('labs', function (Blueprint $table) {
|
||||
$table->foreignId('batch_id')
|
||||
->nullable()
|
||||
->after('product_id')
|
||||
->constrained('batches')
|
||||
->onDelete('cascade')
|
||||
->comment('The batch this lab test belongs to');
|
||||
|
||||
$table->index('batch_id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('labs', function (Blueprint $table) {
|
||||
$table->dropForeign(['batch_id']);
|
||||
$table->dropIndex(['batch_id']);
|
||||
$table->dropColumn('batch_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,49 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('business_modules', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('business_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('module_key');
|
||||
$table->boolean('enabled')->default(true);
|
||||
$table->json('config')->nullable();
|
||||
$table->json('limits')->nullable();
|
||||
$table->string('plan')->nullable();
|
||||
$table->decimal('monthly_price', 10, 2)->nullable();
|
||||
$table->timestamp('activated_at')->nullable();
|
||||
$table->unsignedBigInteger('activated_by')->nullable();
|
||||
$table->timestamp('expires_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
// Foreign key for activated_by
|
||||
$table->foreign('activated_by')->references('id')->on('users')->nullOnDelete();
|
||||
|
||||
// Unique constraint
|
||||
$table->unique(['business_id', 'module_key']);
|
||||
|
||||
// Indexes
|
||||
$table->index('business_id');
|
||||
$table->index('module_key');
|
||||
$table->index('enabled');
|
||||
$table->index('expires_at');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('business_modules');
|
||||
}
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user