19 KiB
Claude Code Context
📌 IMPORTANT: Check Personal Context Files
ALWAYS read claude.kelly.md first - Contains personal preferences and session tracking workflow
📘 Platform Conventions
For ALL naming, routing, and architectural conventions, see:
/docs/platform_naming_and_style_guide.md
This guide is the source of truth for:
- Terminology (no vendor references)
- Routing patterns
- Model naming
- UI copy standards
- Commit message rules
- Database conventions
🚨 Critical Mistakes You Make
1. Business Isolation (MOST COMMON!)
❌ Wrong: Component::findOrFail($id) then check business_id
✅ Right: Component::where('business_id', $business->id)->findOrFail($id)
Why: Prevents ID enumeration across tenants (see audit: BomController vulnerability)
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
Check docs/URL_STRUCTURE.md BEFORE route changes.
/b/*→ Buyers only/s/*→ Sellers only/admin→ Super admins only
3. Filament Usage Boundary
Filament = /admin ONLY (super admin tools)
DO NOT use Filament for /b/ or /s/ - use DaisyUI + Blade instead
Why: Filament is admin panel framework, not customer-facing UI
4. Multi-Tenancy Architecture
We do NOT use spatie/laravel-multitenancy - manual business_id scoping Why: Two-sided marketplace needs cross-business queries (buyers browse all sellers' products) Orders link TWO businesses: buyer's business_id + seller's product→brand→business_id
5. Middleware Protection
ALL routes need auth + user type middleware except public pages
Pattern: ->middleware(['auth', 'verified', 'buyer']) or ['seller', 'approved']
Caught in audit: Unprotected /onboarding/* routes - now fixed
6. PostgreSQL Migrations
❌ No IF/ELSE logic in migrations (not supported) ✅ Use Laravel Schema builder or conditional PHP code
7. Git Workflow - ALWAYS Use PRs
❌ NEVER push directly to develop or master
❌ NEVER bypass pull requests
❌ NEVER use GitHub CLI (gh) - we use Gitea
✅ ALWAYS create a feature branch and PR for review
✅ ALWAYS use Gitea API for PR creation (see below)
Why: PRs are required for code review, CI checks, and audit trail
Creating PRs via Gitea API:
# Requires GITEA_TOKEN environment variable
curl -X POST "https://git.spdy.io/api/v1/repos/Cannabrands/hub/pulls" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"title": "PR title", "body": "Description", "head": "feature-branch", "base": "develop"}'
Infrastructure Services:
| Service | Host | Notes |
|---|---|---|
| Gitea | https://git.spdy.io |
Git repository |
| Woodpecker CI | https://ci.spdy.io |
CI/CD pipelines |
| Docker Registry | 10.100.9.70:5000 |
Local registry (insecure) |
PostgreSQL (Dev)
Host: 10.100.6.50
Port: 5432
Database: cannabrands_dev
Username: cannabrands
Password: SpDyCannaBrands2024
URL: postgresql://cannabrands:SpDyCannaBrands2024@10.100.6.50:5432/cannabrands_dev
PostgreSQL (CI) - Ephemeral container for isolated tests
Host: postgres (service name)
Port: 5432
Database: testing
Username: testing
Password: testing
Redis
Host: 10.100.9.50
Port: 6379
Password: SpDyR3d1s2024!
URL: redis://:SpDyR3d1s2024!@10.100.9.50:6379
MinIO (S3-Compatible Storage)
Endpoint: 10.100.9.80:9000
Console: 10.100.9.80:9001
Region: us-east-1
Path Style: true
Bucket: cannabrands
Access Key: cannabrands-app
Secret Key: cdbdcd0c7b6f3994d4ab09f68eaff98665df234f
Gitea Container Registry (for CI image pushes)
Registry: git.spdy.io
User: kelly@spdy.io
Token: c89fa0eeb417343b171f11de6b8e4292b2f50e2b
Scope: write:package
Woodpecker secrets: registry_user, registry_password
CI/CD Notes:
- Uses Kaniko for Docker builds (no Docker daemon, avoids DNS issues)
- Images pushed to
git.spdy.io/cannabrands/hub(k8s can pull without insecure config) - Base images pulled from local registry
10.100.9.70:5000(Kaniko handles insecure) - Deploy:
develop→ dev.cannabrands.app,master→ cannabrands.app
8. User-Business Relationship (Pivot Table)
Users connect to businesses via business_user pivot table (many-to-many).
❌ Wrong: User::where('business_id', $id) — users table has NO business_id column
✅ Right: User::whereHas('businesses', fn($q) => $q->where('businesses.id', $id))
Pivot table columns: business_id, user_id, role, role_template, is_primary, permissions
Why: Allows users to belong to multiple businesses with different roles per business
9. Styling - DaisyUI/Tailwind Only
❌ NEVER use inline style="" attributes in Blade templates
✅ ALWAYS use DaisyUI/Tailwind utility classes
Why: Consistency, maintainability, theme switching, and better performance
Correct patterns:
- Colors: Use
bg-primary,text-primary,bg-success, etc. (defined inresources/css/app.css) - Spacing: Use
p-4,m-2,gap-3(Tailwind utilities) - Layout: Use
flex,grid,items-center(Tailwind utilities) - Custom colors: Add to
resources/css/app.csstheme variables, NOT inline
Exception: Only use inline styles for truly dynamic values from database (e.g., user-uploaded brand colors)
10. Suites Architecture - NOT Modules (CRITICAL!)
❌ NEVER use has_crm, has_marketing, or other legacy module flags
❌ NEVER create routes like seller.crm.* (without .business.)
❌ NEVER extend seller.crm.layouts.crm layout (outdated CRM module layout)
✅ ALWAYS use Suites system (Sales Suite, Processing Suite, etc.)
✅ ALWAYS use route pattern seller.business.crm.* (includes {business} segment)
✅ ALWAYS extend layouts.seller for seller views
Why: We migrated from individual modules to a Suites architecture. CRM features are now part of the Sales Suite.
See: docs/SUITES_AND_PRICING_MODEL.md for full architecture
The 7 Suites:
- Sales Suite - Products, Orders, Buyers, CRM, Marketing, Analytics, Orchestrator
- Processing Suite - Extraction, Wash Reports, Yields (internal)
- Manufacturing Suite - Work Orders, BOM, Packaging (internal)
- Delivery Suite - Pick/Pack, Drivers, Manifests (internal)
- Management Suite - Finance, AP/AR, Budgets (Canopy only)
- Brand Manager Suite - Read-only brand portal (external partners)
- Dispensary Suite - Buyer marketplace (dispensaries)
Legacy module flags still exist in database but are deprecated. Suite permissions control access now.
11. Media Storage - MinIO Architecture (CRITICAL!)
❌ NEVER use Storage::disk('public') for brand/product media
✅ ALWAYS use Storage (respects .env FILESYSTEM_DISK=minio)
Why: All media lives on MinIO (S3-compatible storage), not local disk. Using wrong disk breaks production images.
⚠️ BLADE TEMPLATE RULES (CRITICAL):
❌ NEVER use /storage/ prefix in image src attributes
❌ NEVER use asset('storage/...') for media
✅ ALWAYS use dynamic image routes with model methods
Correct Image Display Patterns:
{{-- Product images - use getImageUrl() method --}}
<img src="{{ $product->getImageUrl('medium') }}" alt="{{ $product->name }}">
<img src="{{ $product->getImageUrl('thumb') }}" alt="{{ $product->name }}">
{{-- Brand logos - use getLogoUrl() method --}}
<img src="{{ $brand->getLogoUrl('medium') }}" alt="{{ $brand->name }}">
{{-- In Alpine.js - use route() helper --}}
<img :src="`{{ url('/images/product/') }}/${product.hashid}/400`">
URL Patterns (for accessing images):
- Product image:
/images/product/{product_hashid}/{width?}- Example:
/images/product/78xd4/400(400px width)
- Example:
- Brand logo:
/images/brand-logo/{brand_hashid}/{width?}- Example:
/images/brand-logo/75pg7/600(600px thumbnail)
- Example:
- Brand banner:
/images/brand-banner/{brand_hashid}/{width?}- Example:
/images/brand-banner/75pg7/1344(1344px banner)
- Example:
Product Image Storage (TWO METHODS): Products can store images in TWO ways - always check both:
- Direct
image_pathcolumn - Single image stored directly on product- Access via
$product->getImageUrl()method - Path stored like:
businesses/cannabrands/brands/thunder-bud/products/TB-AM-AZ1G/images/alien-marker.png
- Access via
images()relation - Multiple images inproduct_imagestable- Access via
$product->imagescollection - Used for galleries with multiple images
- Access via
When loading product images for display:
// Check BOTH methods - direct image_path first, then relation
if ($product->image_path) {
$imageUrl = $product->getImageUrl('medium');
} elseif ($product->images->count() > 0) {
$imageUrl = $product->images->first()->url;
}
Storage Path Requirements (on MinIO):
- Brand logos/banners:
businesses/{business_slug}/brands/{brand_slug}/branding/{filename}- Example:
businesses/cannabrands/brands/thunder-bud/branding/logo.png
- Example:
- Product images:
businesses/{business_slug}/brands/{brand_slug}/products/{product_sku}/images/{filename}- Example:
businesses/cannabrands/brands/thunder-bud/products/TB-BM-AZ1G/images/black-maple.png
- Example:
DO NOT:
- Use
/storage/prefix in Blade templates for ANY media - Use
asset('storage/...')for ANY media - Use numeric IDs in paths (e.g.,
products/14/) - Use hashids in storage paths
- Skip business or brand directories
- Use
Storage::disk('public')anywhere in media code - Assume images are ONLY in
images()relation - checkimage_pathtoo!
See Comments In:
app/Models/Brand.php(line 47) - Brand asset pathsapp/Models/Product.php(line 108) - Product image pathsapp/Http/Controllers/ImageController.php(line 10) - Critical storage rulesdocs/architecture/MEDIA_STORAGE.md- Complete documentation
This has caused multiple production outages - review docs before ANY storage changes!
12. Dashboard & Metrics Performance (CRITICAL!)
Production outages have occurred from violating these rules.
The Golden Rule
NEVER compute aggregations in HTTP controllers. Dashboard data comes from Redis, period.
What Goes Where
| Location | Allowed | Not Allowed |
|---|---|---|
| Controller | Redis::get(), simple lookups by ID |
->sum(), ->count(), ->avg(), loops with queries |
| Background Job | All aggregations, joins, complex queries | N/A |
❌ BANNED Patterns in Controllers:
// BANNED: Aggregation in controller
$revenue = Order::sum('total');
// BANNED: N+1 in loop
$items->map(fn($i) => Order::where('product_id', $i->id)->sum('qty'));
// BANNED: Query per day/iteration
for ($i = 0; $i < 30; $i++) {
$data[] = Order::whereDate('created_at', $date)->sum('total');
}
// BANNED: Selecting columns that don't exist
->select('id', 'stage_1_metadata') // Column doesn't exist!
✅ REQUIRED Pattern:
// Controller: Just read Redis
public function analytics(Business $business)
{
$data = Redis::get("dashboard:{$business->id}:analytics");
if (!$data) {
CalculateDashboardMetrics::dispatch($business->id);
return view('dashboard.analytics', ['data' => $this->emptyState()]);
}
return view('dashboard.analytics', ['data' => json_decode($data, true)]);
}
// Background Job: Do all the heavy lifting
public function handle()
{
// Batch query - ONE query for all products
$salesByProduct = OrderItem::whereIn('product_id', $productIds)
->groupBy('product_id')
->selectRaw('product_id, SUM(quantity) as total')
->pluck('total', 'product_id');
Redis::setex("dashboard:{$businessId}:analytics", 900, json_encode($data));
}
Before Merging Dashboard PRs:
- Search for
->sum(,->count(,->avg(in the controller - Search for
->map(functionwith queries inside - If found → Move to background job
- Query count must be < 20 for any dashboard page
The Architecture
BACKGROUND (every 10 min) HTTP REQUEST
======================== =============
┌─────────────────────┐ ┌─────────────────────┐
│ CalculateMetricsJob │ │ DashboardController │
│ │ │ │
│ - Heavy queries │ │ - Redis::get() only │
│ - Joins │──► Redis ──►│ - No aggregations │
│ - Aggregations │ │ - No loops+queries │
│ - Loops are OK here │ │ │
└─────────────────────┘ └─────────────────────┘
Takes 5-30 sec Takes 10ms
Runs in background User waits for this
Prevention Checklist for Future Dashboard Work
- All
->sum(),->count(),->avg()are in background jobs, not controllers - No
->map(functionwith queries inside in controllers - Redis keys exist after job runs (
redis-cli KEYS "dashboard:*") - Job completes without errors (check
storage/logs/worker.log) - Controller only does
Redis::get()for metrics - Column names in
->select()match actual database schema
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.
Local Development Setup
First-time setup or fresh database:
./vendor/bin/sail artisan dev:setup --fresh
This command:
- Runs migrations (use
--freshto drop all tables first) - Prompts to seed dev fixtures (users, businesses, brands)
- Seeds brand profiles and orchestrator profiles
- Displays test credentials when complete
Options:
--fresh— Drop all tables and re-run migrations--skip-seed— Skip the seeding prompt
Test Credentials (seeded by dev:setup):
| Role | Password | |
|---|---|---|
| Super Admin | admin@cannabrands.com | password |
| Admin | admin@example.com | password |
| Seller | seller@example.com | password |
| Buyer | buyer@example.com | password |
| Cannabrands Owner | cannabrands-owner@example.com | password |
| Brand Manager | brand-manager@example.com | password |
Testing & Git
Before commit:
php artisan test --parallel # REQUIRED
./vendor/bin/pint # REQUIRED
Commit Messages:
- ❌ DO NOT include Claude Code signature/attribution in commit messages
- ❌ DO NOT add "🤖 Generated with Claude Code" or "Co-Authored-By: Claude"
- ✅ Write clean, professional commit messages without AI attribution
Credentials: See "Local Development Setup" section above
Branches: Never commit to master/develop directly - use feature branches
CI/CD: Woodpecker checks syntax → Pint → tests → Docker build
Common Query Patterns
// 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!
Architecture Docs (Read When Needed)
🎯 START HERE:
SYSTEM_ARCHITECTURE.md- Complete system guide covering ALL architectural patterns, security rules, modules, departments, performance, and development workflow
Deep Dives (when needed):
docs/supplements/departments.md- Department system, permissions, access controldocs/supplements/processing.md- Processing operations (Solventless vs BHO, conversions, wash batches)docs/supplements/permissions.md- RBAC, impersonation, audit loggingdocs/supplements/precognition.md- Real-time form validation migrationdocs/supplements/analytics.md- Product tracking, email campaignsdocs/supplements/batch-system.md- Batch management and COAsdocs/supplements/performance.md- Caching, indexing, N+1 preventiondocs/supplements/horizon.md- Queue monitoring and deployment
Architecture Details:
docs/architecture/URL_STRUCTURE.md- READ BEFORE routing changesdocs/architecture/DATABASE.md- READ BEFORE migrationsdocs/architecture/API.md- API endpoints and contracts
Other:
VERSIONING_AND_AUDITING.md- Quicksave and Laravel AuditingCONTRIBUTING.md- Detailed git workflow
Performance Requirements
Database Queries:
- NEVER write N+1 queries - always use eager loading (
with()) for relationships - NEVER run queries inside loops - batch them before the loop
- Avoid multiple queries when one JOIN or subquery works
- Dashboard/index pages should use MAX 5-10 queries total, not 50+
- Use
DB::enableQueryLog()mentally - if a page would log 20+ queries, refactor - Cache expensive aggregations (Redis, 5-min TTL) instead of recalculating every request
- Test with
DB::listen()or Laravel Debugbar before committing controller code
Before submitting controller code, verify:
- No queries inside foreach/map loops
- All relationships eager loaded
- Aggregations done in SQL, not PHP collections
- Would this cause a 503 under load? If unsure, simplify.
Examples:
// ❌ N+1 query - DON'T DO THIS
$orders = Order::all();
foreach ($orders as $order) {
echo $order->customer->name; // Query per iteration!
}
// ✅ Eager loaded - DO THIS
$orders = Order::with('customer')->get();
// ❌ Query in loop - DON'T DO THIS
foreach ($products as $product) {
$stock = Inventory::where('product_id', $product->id)->sum('quantity');
}
// ✅ Batch query - DO THIS
$stocks = Inventory::whereIn('product_id', $products->pluck('id'))
->groupBy('product_id')
->selectRaw('product_id, SUM(quantity) as total')
->pluck('total', 'product_id');
What You Often Forget
✅ Scope by business_id BEFORE finding by ID ✅ Use Eloquent (never raw SQL) ✅ Protect routes with middleware ✅ DaisyUI for buyer/seller, Filament only for admin ✅ NO inline styles - use Tailwind/DaisyUI classes only ✅ Run tests before committing ✅ Eager load relationships to prevent N+1 queries ✅ No queries inside loops - batch before the loop