Files
hub/CLAUDE.md

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 in resources/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.css theme 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:

  1. Sales Suite - Products, Orders, Buyers, CRM, Marketing, Analytics, Orchestrator
  2. Processing Suite - Extraction, Wash Reports, Yields (internal)
  3. Manufacturing Suite - Work Orders, BOM, Packaging (internal)
  4. Delivery Suite - Pick/Pack, Drivers, Manifests (internal)
  5. Management Suite - Finance, AP/AR, Budgets (Canopy only)
  6. Brand Manager Suite - Read-only brand portal (external partners)
  7. 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)
  • Brand logo: /images/brand-logo/{brand_hashid}/{width?}
    • Example: /images/brand-logo/75pg7/600 (600px thumbnail)
  • Brand banner: /images/brand-banner/{brand_hashid}/{width?}
    • Example: /images/brand-banner/75pg7/1344 (1344px banner)

Product Image Storage (TWO METHODS): Products can store images in TWO ways - always check both:

  1. Direct image_path column - 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
  2. images() relation - Multiple images in product_images table
    • Access via $product->images collection
    • Used for galleries with multiple images

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

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 - check image_path too!

See Comments In:

  • app/Models/Brand.php (line 47) - Brand asset paths
  • app/Models/Product.php (line 108) - Product image paths
  • app/Http/Controllers/ImageController.php (line 10) - Critical storage rules
  • docs/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:

  1. Search for ->sum(, ->count(, ->avg( in the controller
  2. Search for ->map(function with queries inside
  3. If found → Move to background job
  4. 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(function with 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 --fresh to 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 Email 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 control
  • docs/supplements/processing.md - Processing operations (Solventless vs BHO, conversions, wash batches)
  • docs/supplements/permissions.md - RBAC, impersonation, audit logging
  • docs/supplements/precognition.md - Real-time form validation migration
  • docs/supplements/analytics.md - Product tracking, email campaigns
  • docs/supplements/batch-system.md - Batch management and COAs
  • docs/supplements/performance.md - Caching, indexing, N+1 prevention
  • docs/supplements/horizon.md - Queue monitoring and deployment

Architecture Details:

  • docs/architecture/URL_STRUCTURE.md - READ BEFORE routing changes
  • docs/architecture/DATABASE.md - READ BEFORE migrations
  • docs/architecture/API.md - API endpoints and contracts

Other:

  • VERSIONING_AND_AUDITING.md - Quicksave and Laravel Auditing
  • CONTRIBUTING.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:

  1. No queries inside foreach/map loops
  2. All relationships eager loaded
  3. Aggregations done in SQL, not PHP collections
  4. 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