Compare commits

...

207 Commits

Author SHA1 Message Date
Jon Leopard
6f353b63f7 fix: replace image placeholders with Lucide icons
- Replace "No image" text with icon-[lucide--image] icons
- Configure iconify plugin with prefixes (lucide, hugeicons, ri)
- Fix infinite 404 loop by using proper icon system
- Use layered approach: icon underneath, image overlays when loaded
- Apply to product images, image grid, and variety images

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 21:13:20 -07:00
Jon Leopard
954a4988b5 hotfix: fix infinite 404 loop on product edit page
Replace missing placeholder.png with inline SVG data URI to stop infinite error handler loop.

The image error handler was causing 1req/sec 404 floods when images failed to load.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 20:10:47 -07:00
Jon Leopard
4ac13268d9 fix: add missing placeholder.png to stop 404 loop on product edit page
The product image error handler was causing infinite 404 requests when images failed to load. This adds a placeholder image to break the loop.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 19:30:20 -07:00
Jon
84f364de74 Merge pull request 'Cleanup product PR: Remove debug files and add tests' (#32) from fix/cleanup-product-pr-v2 into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/32
2025-11-06 23:39:28 +00:00
Jon Leopard
39c955cdc4 Fix ProductLineController route names
Changed all redirects from 'seller.business.products.index1' to
'seller.business.products.index' to match the actual route definition.

The index1 route doesn't exist in origin/develop, causing test failures.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 16:31:46 -07:00
Jon Leopard
e02ca54187 Update drop shadow values to match dashboard styling
Changed all card shadows from shadow-xl to shadow to be consistent
with the dashboard page styling.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 16:25:28 -07:00
Jon Leopard
ac46ee004b Fix product edit header: theme support and remove breadcrumb
Fixed top header container styling issues:
- Changed hard-coded bg-white/gray colors to theme-aware DaisyUI classes
- Restored proper shadow (shadow-xl instead of shadow-sm)
- Updated all color classes to use base-* theme variables
- Converted buttons to proper DaisyUI btn components
- Removed breadcrumb navigation element

Container now properly respects theme switcher (light/dark mode).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 16:25:28 -07:00
Jon Leopard
17a6eb260d Add comprehensive tests for ProductLineController
Added test coverage for all ProductLineController methods:
- Store: validates required name, uniqueness per business, cross-business duplicates OK
- Update: validates name, uniqueness, business isolation
- Destroy: deletes product line, business isolation

Tests verify business_id scoping prevents cross-tenant access.

Note: Tests use standard HTTP methods (not JSON) which may have CSRF token issues
in current test environment (project-wide issue).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 16:25:28 -07:00
Jon Leopard
5ea80366be Add comprehensive tests for ProductImageController
Added test coverage for all ProductImageController methods:
- Upload: validates dimensions, file type, max 6 images, business isolation
- Delete: handles primary image reassignment, business isolation
- Reorder: updates sort_order, sets first as primary, business isolation
- SetPrimary: updates is_primary flag, cross-product validation

Also fixed ProductImage model to include sort_order in fillable/casts.

Note: Tests currently fail with 419 CSRF errors (project-wide test issue affecting
PUT/POST/DELETE requests). Tests are correctly structured and will pass once CSRF
handling is fixed in the test environment.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 16:25:28 -07:00
Jon Leopard
99aa0cb980 Remove development and test artifacts from product PR
Removed debugging tools and test files that should not be in production:
- check_blade.php and check_blade.js (Blade syntax checkers)
- StorageTestController and storage-test view (MinIO testing scaffolds)
- edit.blade.php.backup and edit1.blade.php (development iterations)
- Storage test routes from web.php

These files were used during development but are not needed in the codebase.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 16:25:28 -07:00
Jon
3de53a76d0 Merge pull request 'docs: add comprehensive guide for keeping feature branches up-to-date' (#30) from docs/add-feature-branch-sync-guide-clean into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/30
2025-11-06 22:17:08 +00:00
Jon Leopard
7fa9b6aff8 docs: add comprehensive guide for keeping feature branches up-to-date
Added new section "Keeping Your Feature Branch Up-to-Date" covering:
- Daily start-of-work routine for syncing with develop
- Merge vs rebase best practices for teams
- Step-by-step conflict resolution guide
- When and how to ask for help with complex conflicts
- Real-world example of multi-day feature work

This addresses common questions from contributors about branch
management and helps prevent large merge conflicts by encouraging
regular syncing with develop.
2025-11-06 15:05:27 -07:00
kelly
79e156bd24 Merge pull request 'feature/product-page-migrate' (#28) from feature/product-page-migrate into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/28
2025-11-06 07:11:21 +00:00
Kelly
12a6a8eb69 chore: trigger CI re-run 2025-11-05 23:58:34 -07:00
Kelly
eb71477ec1 fix: apply Laravel Pint code style fixes
Fixed 5 style issues:
- ProductImageController: trailing comma in multiline array
- ProductLineController: concat space formatting
- StorageTestController: removed unused imports
- FileStorageHelper: removed superfluous PHPDoc tags, unary operator spacing
- check_blade.php: concat space and not operator spacing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-05 23:54:45 -07:00
Kelly
2ed54eced2 Merge branch 'develop' into feature/product-page-migrate 2025-11-05 23:51:56 -07:00
Kelly
32fd2b0ab8 feat: migrate product edit page to new top header layout
- Replace sidebar layout with top header design
- Add product image thumbnail, badges (Active/Featured), and action buttons
- Implement real-time badge toggling with inline JavaScript
- Add one-active-product-per-brand validation with force-activate option
- Standardize checkbox styling with DaisyUI components
- Update terminology from "Default" to "Primary" for images
- Add new models: ProductLine, ProductPackaging, Unit
- Add product line management and image sorting
- Add styling rules to CLAUDE.md for consistency

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-05 23:41:37 -07:00
Kelly
ded374de3c feat: upgrade Nexus design system from HTML to Laravel
Replace Nexus HTML v3.1.0 with Nexus Laravel v3.1.0 source files

- Replace static HTML files with Laravel Blade templates
- Add Blade partials and layouts for design system
- Add updated styles (app.css, daisyui.css, typography.css, etc.)
- Add JavaScript components and page-specific scripts
- Update package.json with new dependencies (apexcharts, choices.js, filepond, flatpickr, quill, simplebar, sortablejs, swiper, zod)
- Add Tailwind 4 and DaisyUI 5 integration
- Include public assets (images, fonts, icons)

These files will serve as source/reference for implementing the new design system in the main Laravel application.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-04 17:48:28 -08:00
Kelly
1cd11cbf67 Update package-lock.json 2025-11-04 17:30:22 -08:00
Jon
26bf7ac377 Merge pull request 'Security Fix: Cart Business Authorization' (#26) from feature/fix-cart-business-authorization into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/26
2025-11-05 00:28:45 +00:00
Jon Leopard
ac1084d6fe test: add security tests for cart business authorization
Add comprehensive security test suite to validate:
- business_id storage in cart records
- Cross-user cart modification prevention
- Cross-session cart manipulation prevention
- Business scoping enforcement
- Cart ownership verification

8 new tests with 16 assertions ensure cart operations are
properly isolated by business and user/session.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-04 15:18:22 -07:00
Jon Leopard
1e2a579c4f feat: add business_id to Cart table for security isolation
Add business_id column to carts table with:
- Foreign key constraint to businesses table
- Index for query performance
- Backfill logic from brands.business_id
- business() relationship in Cart model

This enables proper business scoping and audit trails for cart
operations, required for cannabis compliance.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-04 15:17:52 -07:00
kelly
37394786be Merge pull request 'feature/fix-buyer-routes' (#25) from feature/fix-buyer-routes into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/25
2025-11-04 21:48:26 +00:00
kelly
0ce850decf fix: update OrderFactory to use valid order statuses
Updates the OrderFactory to use statuses that match the database
check constraint. Replaces invalid statuses (pending, picked,
manifested, shipped) with valid ones (new, accepted, in_progress,
ready_for_manifest, ready_for_delivery, delivered, cancelled).

Fixes test failures caused by check constraint violations.
2025-11-04 14:37:40 -07:00
kelly
02facc77c2 fix: add delivery_date and delivery_instructions columns to orders table
Adds nullable delivery_date and delivery_instructions columns to
support delivery scheduling in orders. Fixes failing tests that
expected these columns to exist.
2025-11-04 14:05:09 -07:00
kelly
a5640375c3 fix: add missing buyer route definitions for backward compatibility
Add legacy redirect routes for buyer orders, invoices, and business profile
that redirect to the new business-scoped routes. This maintains backward
compatibility with existing tests and links while preserving the secure
multi-tenant architecture.

Changes:
- Add /b/orders redirect route (buyer.orders.show)
- Add /b/invoices redirect route (buyer.invoices.show)
- Add /b/business/profile redirect route
- Fix route redirects to use business slug explicitly
- Update SmokeTest to expect redirects instead of 200 OK
- Update OrderSurchargeDisplayTest to follow redirects

Test Results:
- Fixed 7 previously failing route tests
- All smoke tests now passing (19/19)
- OrderSurchargeDisplayTest all passing (4/4)
- 79/82 tests passing (1 pre-existing schema issue, 2 skipped)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-04 11:43:39 -07:00
kelly
fb28283f39 Merge branch 'develop' of https://code.cannabrands.app/Cannabrands/hub into feature/fix-multi-tenancy-architecture 2025-11-04 11:15:59 -07:00
kelly
00903d7cb7 fix: improve authorization consistency and remove accidental MySQL service
- Update OrderController methods to use belongsToBusiness() pattern
- Remove canAccessOrder() helper method
- Remove MySQL service from docker-compose.yml
- Maintain PostgreSQL as the only database service

Fixes critical security issue where users could access orders from
different business contexts.
2025-11-04 10:57:29 -07:00
Jon
18de0fc97a Merge pull request 'feat: add Kubernetes local development environment with git worktree support' (#22) from fix/k8s-worktree-support into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/22
2025-11-03 22:51:33 +00:00
Jon Leopard
4830d53f63 Merge branch 'develop' into fix/k8s-worktree-support
Resolve conflicts from documentation reorganization (PR #24)
2025-11-03 15:32:50 -07:00
Jon
43625660bc Merge pull request 'docs: consolidate and reorganize documentation structure' (#24) from docs/consolidate-documentation into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/24
2025-11-03 22:28:48 +00:00
Jon Leopard
985aec9c8a chore: add gitignore rules for core dumps and random images 2025-11-03 15:18:21 -07:00
Jon Leopard
544c955cf4 docs: consolidate and reorganize documentation structure
- Update CONTRIBUTING.md to reflect protected branches and PR workflow
- Organize /docs into clear categories:
  - architecture/ (DATABASE, API, URL_STRUCTURE)
  - development/ (SETUP, LOCAL_DEV, DOCKER)
  - deployment/ (KUBERNETES, CI_CD)
  - features/ (BATCH_SYSTEM, NOTIFICATIONS, REAL_TIME)
- Delete stale planning/temporary markdown files from root
- Streamline .woodpecker/ to essential release management docs
- Remove GIT_BRANCHING_STRATEGY (consolidated into CONTRIBUTING.md)

Benefits:
- Single source of truth for workflow (CONTRIBUTING.md)
- Clear doc hierarchy prevents duplication
- Less drift with fewer files to maintain
- Easier for Claude/devs to find correct documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-03 15:15:10 -07:00
Jon Leopard
fec27f1aeb feat: add automatic worktree/root detection for k8s volume mounts
- Add git worktree detection logic to Makefile
- Dynamically set K8S_VOLUME_PATH based on location (worktree or root)
- Update deployment.yaml and reverb.yaml to use K8S_VOLUME_PATH variable
- Document k3d cluster creation command with dual volume mounts
- Enables running make k-dev from both worktrees and project root

This allows multiple isolated k8s environments to run simultaneously:
- Run develop branch from project root
- Run feature branches from worktrees
- Each gets correct volume mount automatically

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-03 14:44:16 -07:00
Jon Leopard
c898c02b8b fix(k8s): correct Reverb volume mount path for k3d
- Change volume path from full macOS path to k3d-mounted path
- Use /worktrees/${WORKTREE_NAME} instead of full absolute path
- Matches web deployment volume mount pattern
- Fixes FailedMount error in k3d cluster

Tested: Reverb pod now starts successfully and serves WebSocket
connections on port 8080 (ws://reverb.{K8S_HOST}:8080)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-03 14:16:57 -07:00
Jon Leopard
4ea1dbd1c2 feat(k8s): add Reverb WebSocket server to local k8s environment
- Add k8s/local/reverb.yaml manifest for Reverb deployment
- Configure Reverb to run on port 8080 with Sail image
- Mount code volume for live reloading
- Update Makefile k-dev target to deploy Reverb
- Add WebSocket endpoint info to success message
- Achieves production parity across all development environments

This completes the Reverb configuration across all environments:
 Sail (docker-compose.yml)
 K8s Local (k8s/local/reverb.yaml)
 K8s Production (k8s/base/reverb-deployment.yaml)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-03 13:37:21 -07:00
Jon Leopard
b847f3745e docs: add bugfix workflow and branch protection guidelines
Added comprehensive guidance for handling bugs affecting multiple feature branches:
-  Recommended: Create fix branch → PR to develop → pull into features
- ⚠️ Not recommended: Direct push to develop (bypasses review)
-  Never: Merge feature branches together (creates coupling)

Also documented branch protection settings for Gitea:
- Protect develop branch (require PRs)
- Protect master branch (stricter rules)
- Admin override capabilities for emergencies

Addresses team question: "How do we handle bugfixes that both branches need?"
Answer: Fix branch → develop → feature branches pull from develop

Golden Rule: "Feature branches merge to develop, not to each other"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-02 16:27:33 -07:00
Jon Leopard
00fcfe8a21 docs: clean up and streamline k8s documentation
- Simplify DEVELOPMENT.md K8s section from 550 lines to concise quick-start
- Reference new comprehensive k8s docs (K8S_LOCAL_SETUP.md, K8S_LIKE_SAIL.md)
- Delete TODO_KUBERNETES.md (tasks completed in this PR)
- Remove outdated manual k8s setup instructions
- Focus on 'make k-*' commands that mirror Sail workflow

This makes the docs easier to navigate and points developers to the
right resources without overwhelming them with implementation details.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-02 15:53:41 -07:00
kelly
95bad62728 Route Fix
Summary of Route Fix
Problem: After adding business scoping to buyer routes (/b/{business}/orders), all the view files were still using the old route names without the business. prefix and without the $business parameter. Solution: Updated 12 buyer view files to use new business-scoped routes:
Key Changes:
buyer.orders.index → buyer.business.orders.index
buyer.cart.index → buyer.business.cart.index
buyer.checkout.process → buyer.business.checkout.process
buyer.invoices.show → buyer.business.invoices.show
buyer.favorites.* → buyer.business.favorites.*
Files Updated:
buyer/orders/show.blade.php
buyer/orders/index.blade.php
buyer/invoices/show.blade.php
buyer/invoices/index.blade.php
buyer/cart/index.blade.php
buyer/checkout/index.blade.php
buyer/checkout/success.blade.php
buyer/dashboard.blade.php
components/buyer-sidebar.blade.php
layouts/buyer-app-with-sidebar.blade.php
buyer/marketplace/product.blade.php
buyer/marketplace/brand.blade.php
2025-11-02 14:46:46 -08:00
Jon Leopard
a7074d55e1 fix: improve git version detection to support worktrees
Updated AppServiceProvider to properly detect git repositories in both
regular repos and worktrees by checking for .git as either a file or
directory.

Changes:
- Use file_exists() instead of is_dir() to detect .git
- Add 'cd' to git commands to ensure they work in worktrees
- Gracefully fall back to 'unknown' when git metadata is inaccessible
- Add proper shell escaping for security

This fixes the "sha-unknown" issue in k8s when using git worktrees,
where .git is a file pointing to host metadata that isn't accessible
in the container.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-02 15:30:28 -07:00
Jon Leopard
62d3dafe56 feat: add automated worktree creation script with make command 2025-11-02 15:29:13 -07:00
Jon Leopard
6ff88440ff fix: increase liveness/readiness probe timeouts for initial build
First-time startup requires 3-5 minutes for composer install, npm install,
and Vite build. Increased probe delays to prevent premature restarts:
- Liveness: 90s → 300s (5 minutes)
- Readiness: 60s → 240s (4 minutes)

Subsequent starts are still fast (~10 seconds) since code is volume-mounted
and dependencies are already installed.
2025-11-02 15:29:13 -07:00
Jon Leopard
9f07155517 feat: add k-test command for running tests in k8s pod
Adds 'make k-test' command to run tests inside k8s pod, mirroring
the Sail 'make dev-test' workflow. This allows developers to run
tests before pushing without needing Sail running.

Usage:
  make k-test    # Run all tests in k8s pod
2025-11-02 15:29:13 -07:00
Jon Leopard
2bdb752c21 fix: update k8s local dev to match Sail workflow with production parity
## Major Changes

**Deployment Manifest (k8s/local/deployment.yaml):**
- Switch from PHP 8.2 to PHP 8.3 (matches production Dockerfile)
- Add PHP_EXTENSIONS env var for intl, pdo_pgsql, pgsql, redis, gd, zip, bcmath
- Set ABSOLUTE_APACHE_DOCUMENT_ROOT to /var/www/html/public
- Remove init container (Sail-like approach: composer runs in main container)
- Add composer install, npm install, and npm build to startup script
- Use TCP connection checks instead of pg_isready/redis-cli (not in image)
- Increase health check delays and failure thresholds for slower startup

**Makefile:**
- Read DB_USERNAME, DB_PASSWORD, DB_DATABASE from .env (not hardcoded)
- PostgreSQL credentials now match .env for consistent auth

**DNS Setup Script:**
- Add scripts/setup-local-dns.sh for one-time dnsmasq configuration
- Idempotent script that's safe to run multiple times
- Works on macOS with Homebrew dnsmasq

## Architecture

Now fully Sail-like:
- Code volume-mounted from worktree (instant changes)
- Composer/npm run inside container at startup
- No pre-installation needed on host
- Each worktree = isolated k8s namespace
- Database credentials from .env (like Sail)

## Testing

Startup sequence verified:
1. Wait for PostgreSQL + Redis
2. Composer install
3. npm install + build
4. Migrations
5. Cache clearing
6. Apache starts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-02 15:29:05 -07:00
Jon Leopard
bf81929587 feat: add k8s local development setup with git worktree support
Adds Kubernetes local development environment that mirrors Laravel Sail workflow
with namespace isolation per git worktree.

## Features

**K8s Manifests (k8s/local/):**
- Namespace configuration with worktree labels
- PostgreSQL StatefulSet with PVC (isolated per namespace)
- Redis Deployment
- Laravel app Deployment using Sail-like image with volume mounts
- Service exposing ports 80 and 5173 (Vite)
- Ingress with wildcard routing (*.cannabrands.test)

**Makefile Targets (k- prefix):**
- `make k-dev` - Start k8s environment (auto-detects branch/namespace)
- `make k-down` - Stop k8s environment
- `make k-logs` - View app logs
- `make k-shell` - Shell into app container
- `make k-artisan CMD="..."` - Run artisan commands
- `make k-composer CMD="..."` - Run composer
- `make k-vite` - Start Vite dev server in pod
- `make k-status` - Show namespace status

**Documentation:**
- docs/K8S_LOCAL_SETUP.md - Complete setup guide
- docs/K8S_LIKE_SAIL.md - Philosophy and implementation details

## Architecture

Uses Sail-like approach:
- Pre-built PHP 8.2 image with Apache and Node.js
- Code volume-mounted from worktree (instant changes, no rebuilds)
- Each worktree = isolated k8s namespace
- Custom domain per feature: [branch].cannabrands.test

## Workflow

```bash
# One-time k3d setup (see docs/K8S_LOCAL_SETUP.md)

# Per-worktree usage
cd .worktrees/feature-name
make k-dev              # Start isolated k8s env
# Code changes are instant!
make k-down             # Cleanup
```

Follows Laravel community best practice: fast local dev (Sail-like) with
production parity testing in staging cluster.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-02 15:28:55 -07:00
kelly
73d4ecc7f5 Create OrderFactory.php
to fix some failing tests
2025-11-02 13:31:39 -08:00
kelly
5a1570468e B2B Improvements and Fixes
 PHASE 1-2 COMPLETE SUMMARY
4 Traits Created:
 BelongsToBusinessDirectly - For models with direct business_id
 BelongsToBusinessViaBrand - For models via Brand relationship
 BelongsToBusinessViaProduct - For models via Product relationship
 BelongsToBusinessViaBatch - For models via Batch relationship
12 Models Updated:
Direct business_id (9 models):
 Brand - Added trait, removed scopeForBusiness()
 Order - Added trait
 Invoice - Added trait
 Component - Added trait, removed scopeForBusiness()
 Contact - Added trait
 License - Added trait
 Location - Added trait
 Driver - Added trait, removed scopeForCompany()
 Vehicle - Added trait, removed scopeForCompany()
Via relationships (3 models): 10.  Product - BelongsToBusinessViaBrand 11.  Batch - BelongsToBusinessViaProduct 12.  Lab - BelongsToBusinessViaBatch
Bugs Fixed:
 Manifest.php:569 - sellerCompany → sellerBusiness
All models now have consistent API:
Product::forBusiness($business)->get();
Brand::forBusiness($business)->get();
Order::forBusiness($business)->get();
// ... and 9 more models

 Phase 3 Complete: Seller Controller Refactoring
Successfully refactored 9 controllers to use trait-based business scoping:
Controllers Refactored:
Seller/ProductController.php - 5 edits
Replaced manual brand checks with $product->belongsToBusiness($business)
Replaced manual Brand queries with Brand::forBusiness($business)
Seller/Product/BomController.php - 5 edits
Replaced product ownership checks
Replaced Component queries with Component::forBusiness($business)
Simplified nested whereHas for recent components
Seller/BrandSwitcherController.php - 2 edits
Replaced manual Brand queries with trait scoping
Seller/ComponentController.php - 4 edits
Replaced Component queries and ownership checks
Seller/InvoiceController.php - 5 edits
Replaced Product queries with Product::forBusiness($business)
Replaced Invoice queries with nested Product scoping
Replaced manual product ownership checks in invoice validation
VehicleController.php - 4 edits
Replaced Vehicle queries and ownership checks
OrderController.php - 3 edits
Replaced Order queries with Product scoping
Replaced Driver and Vehicle queries
DriverController.php - 4 edits
Replaced Driver queries and ownership checks
BuyerSetupController.php - 1 edit
Replaced Contact query with trait scoping
Key Improvements:
Cleaner code: Replaced verbose manual where('business_id', $business->id) and whereHas('brand', ...) with concise forBusiness($business) calls
Consistent API: All models now use the same scoping pattern
Safer ownership checks: Replaced manual $model->relation->business_id !== $business->id checks with $model->belongsToBusiness($business)
Reduced duplication: Removed repeated scoping logic across controllers
Better maintainability: Changes to scoping logic now only need to be made in one place (the traits)

 Phase 4 Complete: All buyer controllers updated with business scoping
 Phase 5 Complete: TenantScopingTest created with 5 tests
 Phase 6 Complete: Company information update method added
 Code style fixed: Pint applied
 Caches cleared: Routes, config, views all cleared
 Permissions fixed: Storage logs now writable
New Buyer Routes (Business-Scoped):
/b/{business-slug}/cart
/b/{business-slug}/orders
/b/{business-slug}/invoices
/b/{business-slug}/checkout
Marketplace Routes (Cross-Tenant - No Change):
/b/browse - All products
/b/brands - All brands
2025-11-02 13:04:52 -08:00
Jon Leopard
b4c5b24294 docs: refactor CLAUDE.md to follow guardrails-first approach
Restructured project instructions based on best practices from Claude Code usage guide:

- Lead with critical mistakes (business isolation, route prefixes, Filament boundaries)
- Replace "NEVER do X" with " Wrong /  Right" patterns
- Document multi-tenancy architecture (custom, not Spatie)
- Add common query patterns from codebase audit
- Pitch external docs with context (when to read them)
- Keep concise (3.4KB vs previous verbose approach)

Focuses on the 6 most common errors discovered during security audit and architecture review.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-02 11:47:57 -07:00
kelly
af3a2dc61b Update settings.local.json 2025-11-02 10:44:23 -08:00
kelly
52a8fe00e1 kellys claude rules 2025-11-02 10:42:38 -08:00
kelly
e23f3aff2f feat: add scroll position persistence to buyer sidebar menu 2025-11-01 17:39:34 -07:00
kelly
bd001e9547 fix: increase timeout to wait for collapse animation before scrolling 2025-11-01 17:37:39 -07:00
kelly
1933983071 fix: scroll active menu item into view on page load 2025-11-01 17:36:31 -07:00
kelly
ae9de21a16 fix: delay scroll restoration until menus are expanded 2025-11-01 17:35:08 -07:00
kelly
be2b7e56c5 feat: add scroll position persistence to seller sidebar menu 2025-11-01 17:32:02 -07:00
kelly
d3516cdd60 fix: merge impersonate bug fixes into develop 2025-11-01 17:12:13 -07:00
kelly
8e0ce323a1 style: fix code style issues with Laravel Pint 2025-11-01 17:06:27 -07:00
kelly
a284cb3eb6 fix: remove unused ImpersonateManager import from UserResource 2025-11-01 16:40:37 -07:00
kelly
1df59f527a fix: use correct impersonate trait from lab404 package
The User model was trying to use STS\FilamentImpersonate\Concerns\Impersonatable
which doesn't exist. Changed to use Lab404\Impersonate\Models\Impersonate
which is the actual trait provided by the lab404/laravel-impersonate package.
2025-11-01 16:40:37 -07:00
kelly
d3f7a374ec Merge hotfix/impersonate-unused-import into develop 2025-11-01 16:33:55 -07:00
kelly
2380d94d02 fix: remove unused ImpersonateManager import from UserResource 2025-11-01 16:33:53 -07:00
kelly
7c1fe3070f Merge feature/impersonate into develop
Add admin user impersonation feature with custom DaisyUI banner

# Conflicts:
#	app/Models/User.php
2025-11-01 16:26:17 -07:00
kelly
079e211a7c fix: use correct impersonate trait from lab404 package
The User model was trying to use STS\FilamentImpersonate\Concerns\Impersonatable
which doesn't exist. Changed to use Lab404\Impersonate\Models\Impersonate
which is the actual trait provided by the lab404/laravel-impersonate package.
2025-11-01 16:24:37 -07:00
kelly
75c01d54e9 feat: Add platform foundation, impersonation, and seller settings menu 2025-11-01 15:19:07 -07:00
kelly
6158df0443 fix(ci): reorder pipeline steps to install dependencies before php-lint
- Move composer-install before php-lint step
- Fixes "Trait not found" error when linting User.php
- php-lint now runs after vendor/ directory is populated
- Also adds missing filament-impersonate.php config

The php-lint step was failing because it tried to lint files that use
the Impersonatable trait before composer install ran. Now dependencies
are installed first, allowing php -l to resolve all traits and classes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-01 15:13:34 -07:00
kelly
8e50a61811 Merge pull request 'feature/company-settings-menu' (#19) from feature/company-settings-menu into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/19
2025-11-01 21:58:42 +00:00
kelly
b2353bfe23 Add Company settings menu to seller navigation
- Renamed "Organization" to "Company" in seller sidebar
- Renamed "Business Profile" to "Company Information"
- Removed "Locations" and "Contacts" menu items
- Added 8 new settings pages: Orders, Brands, Payments, Invoices, Manage Licenses, Plans and Billing, Notifications, Reports
- Created SettingsController with placeholder methods for all settings pages
- Created placeholder views for all 10 settings pages
- All routes follow pattern: /s/{business-slug}/settings/*
- Routes protected by auth, verified, and approved middleware

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-01 14:54:11 -07:00
kelly
7b1292448a feat: Add platform foundation with modules system and impersonation
- Add modules, business_modules, business_module_usage tables
- Add Module, BusinessModule, BusinessModuleUsage models
- Add HasModules trait to Business model
- Add ModuleService for centralized logic
- Add Filament resources for module management
- Add ModuleSeeder with test modules (SMS, CRM, Inventory, Accounting)
- Add user impersonation for superadmin
- Configure slug-based routing for modules
- Ready for building feature modules like SMS gateway
2025-11-01 13:50:23 -07:00
Jon Leopard
8aa5e51d1c docs: add local Kubernetes development guide and fix PostgreSQL references
- Added comprehensive local K8s development section to DEVELOPMENT.md
  - Covers Docker Desktop K8s, k3d, minikube, and kind
  - Includes quick start guide for deploying to local K8s
  - Added development workflow and troubleshooting sections
  - Comparison table of all local development options

- Updated README.md documentation section
  - Reorganized into Getting Started, Deployment, and Reference sections
  - Added Development Flow Options Summary table
  - Clear guidance on which document covers which development approach

- Fixed MySQL references to PostgreSQL in SETUP.md
  - Changed DB_CONNECTION from mysql to pgsql
  - Updated DB_PORT from 3306 to 5432
  - Changed database name to cannabrands_app for consistency

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-01 10:30:53 -07:00
Jon Leopard
23e46f8085 perf(k8s): increase dev CPU limit from 250m to 1000m
Increased CPU limit from 250m to 1000m (4x increase) and CPU request
from 50m to 100m to prevent CPU throttling in development environment.

**Before:**
- CPU limit: 250m → App severely throttled
- /admin/businesses: 12,404ms
- Database connection: 208ms
- /register: 2-6 seconds

**After:**
- CPU limit: 1000m (1 core)
- Database connection: 71ms (66% improvement)
- Expected page loads: <500ms

This is a development environment resource allocation, not a performance
optimization. The app needs adequate CPU to run without artificial
throttling.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 18:31:37 -07:00
Jon Leopard
4d3401eb84 fix(telescope): register TelescopeServiceProvider to enable Super Admin gate
Added App\Providers\TelescopeServiceProvider to bootstrap/providers.php
so the custom viewTelescope gate is properly registered.

Without this registration, the gate() method was never called, causing
Telescope to be inaccessible even for Super Admin users on non-local
environments.

Now only users with the 'Super Admin' role can access Telescope on
development, staging, and production environments.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 18:16:59 -07:00
Jon Leopard
0c9822b20f fix(seeder): use random ID generation to match system format
Update DevSeeder to generate random order/invoice numbers using uniqid()
to match the system's generation format instead of date-based sequential.

Changes:
- Orders: ORD-XXXXX (random) instead of ORD-YYYYMMDD-XXX
- Invoices: INV-XXXXX (random) instead of INV-YYYYMMDD-XXX

Benefits:
- Consistent with system generation (InvoiceService, CheckoutController)
- Does not reveal business volume to competitors
- Unpredictable for security
- Matches picking ticket format (PT-XXXXX)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 15:26:29 -07:00
Jon Leopard
860bd093c0 Revert "fix(orders): use date-based sequential numbering for orders and invoices"
This reverts commit 41b970a. Seeder should match system format, not vice versa.
System uses random strings for security and to avoid revealing business volume.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 15:23:51 -07:00
Jon Leopard
41b970adc7 fix(orders): use date-based sequential numbering for orders and invoices
Change order and invoice number generation from random strings to
date-based sequential format to match seeder conventions.

Format changes:
- Orders: ORD-YYYYMMDD-XXX (e.g., ORD-20251029-001)
- Invoices: INV-YYYYMMDD-XXX (e.g., INV-20251029-001)
- Sequential counter resets daily

Benefits:
- Human-readable and sortable
- Easy to identify creation date
- Matches seeder format for consistency
- Supports up to 999 orders/invoices per day

Files updated:
- InvoiceService::generateOrderNumber()
- InvoiceService::generateInvoiceNumber()
- CheckoutController::generateOrderNumber()

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 15:20:07 -07:00
Jon Leopard
f5931b490c refactor(picking): remove debug logging from picking ticket implementation
Remove console.log and Log::info debug statements used during development
and debugging of real-time picking progress broadcasting.

Changes:
- Remove console.log statements from pick.blade.php Echo subscription
- Remove Log::info statements from WorkorderController API endpoint
- Keep error logging in catch block for production debugging

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 15:07:29 -07:00
Jon Leopard
ae4ef621b9 fix(picking): use axios instead of fetch for automatic X-Socket-Id header
Changed from fetch() to window.axios.post() to match cart implementation.
The axios interceptor in bootstrap.js automatically adds X-Socket-Id header
to all requests, which is required for ->toOthers() broadcasting to work.

Without X-Socket-Id, the backend can't exclude the sender's socket, causing
broadcasts to not work correctly between tabs.

Follows same pattern as cart real-time updates from commit 77fb5f9b.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 14:51:04 -07:00
Jon Leopard
7b0ee691e9 fix(picking): remove automatic completion at 100%, require manual completion
Remove auto-transition to 'ready_for_invoice' when picking reaches 100%.
Lab workers must now manually click "Complete Picking Ticket" button to
mark an order as complete and generate the invoice.

Status transitions now:
- 'accepted' → 'in_progress': When picking starts (any qty > 0)
- 'in_progress' → 'accepted': When all quantities reset to 0
- 'in_progress' → 'ready_for_invoice': ONLY via manual "Complete" button

This prevents premature completion and gives lab workers explicit control
over when an order is ready for invoicing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 13:31:18 -07:00
Jon Leopard
b82ab7d28b fix(picking): add RedirectResponse return type to pick() method
The pick() method was declared to return only View, but it attempts to
redirect when the order status is not 'accepted' or 'in_progress'.
This caused a TypeError when trying to view already-completed picking tickets.

Changed return type from View to View|RedirectResponse to allow both
valid return paths.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 13:26:30 -07:00
Jon Leopard
d602661e98 fix(picking): only show saving indicator during API call, not during debounce
Move `this.saving = true` from debouncedUpdate() to updateQuantity() so the
loading spinner only appears during the actual API request, not during the
300ms debounce period. This provides better UX by allowing instant visual
feedback on button clicks without showing a loading state unnecessarily.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 13:19:16 -07:00
Jon Leopard
790fefd5cc perf(picking): add 300ms debouncing to prevent request spam
Add debouncing to picking quantity updates following the same pattern
used in cart broadcasting. Prevents spam when users rapidly click +/-
buttons or type in input fields.

Problem:
- Every click sent immediate request + broadcast
- 10 rapid clicks = 10 requests + 10 broadcasts to all workers
- Unnecessary server load and network traffic
- Could cause race conditions with very rapid updates

Solution:
- Added debouncedUpdate() method with 300ms delay
- Clears previous timer on each interaction
- Waits 300ms after LAST interaction before sending ONE request
- Follows exact pattern from cart implementation (commit 4c548fe)

Behavior:
- User clicks + button 10 times rapidly
- UI updates instantly (optimistic)
- After 300ms of inactivity, sends ONE request with final value
- ONE broadcast sent to other workers
- 90% reduction in requests for rapid interactions

Best Practices Applied:
✓ Debouncing with clearTimeout pattern
✓ 300ms delay (same as cart, good UX balance)
✓ Visual feedback with saving indicator
✓ Optimistic UI updates
✓ Consistent with existing cart implementation

Performance Impact:
- Rapid clicking (10 clicks): 10 requests → 1 request
- Typing in input: Multiple requests → 1 request
- Broadcasts reduced proportionally
- Server/network load significantly reduced

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 13:13:46 -07:00
Jon Leopard
011bb7433c fix(picking): sync item counts across tabs when broadcasts are received
Fixed issue where progress ring updated but individual item counts didn't
update on other tabs. Now all displays (counts, badges, input fields) sync
in real-time across all workers.

Root Cause:
- Displays were using localPicked (local state) instead of picked (store)
- When broadcasts updated store, local state didn't sync
- Progress ring worked because it read directly from store

Solution:
1. Changed displays to read from picked (store-backed getter)
   - Status badges now use picked instead of localPicked
   - "Picked" count display now uses picked instead of localPicked

2. Added Alpine watcher to sync localPicked from store
   - Watches picked getter for changes
   - Updates localPicked when store changes (from broadcasts)
   - Skips sync during save to avoid race conditions

Now when Worker A updates an item:
- Worker B's display updates instantly (uses picked from store)
- Worker B's input field syncs (watcher updates localPicked)
- Worker B's status badge updates (uses picked from store)

All tests still pass (75 passing).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 13:11:37 -07:00
Jon Leopard
3b57116c79 feat(picking): add real-time progress broadcasting for collaborative picking
Enable multiple warehouse workers to see live picking progress when working
on the same order simultaneously. Each worker picks different items and sees
real-time updates from other workers without page refreshes.

Use Case:
- 2-3 workers picking different line items from same order
- Workers see synchronized progress ring and item counts
- Eliminates confusion about order completion status
- No need to refresh to see teammate progress

Implementation:
- Event: PickingProgressUpdated broadcasts item updates
- Channel: picking-ticket.{orderId} (private, seller-only)
- Trigger: Broadcasts when picked_qty changes
- Frontend: Echo listener updates Alpine.js store

Changes:
1. Created PickingProgressUpdated event
   - Broadcasts orderId, itemId, pickedQty, progress
   - Uses private channel for security
   - Minimal payload (~80 bytes)

2. Added picking-ticket channel authorization
   - Only authenticated sellers can subscribe
   - Sellers must belong to business selling products in order
   - Prevents unauthorized access

3. Modified WorkorderController to broadcast
   - Broadcasts after picked_qty update
   - Uses toOthers() to avoid echoing to updater
   - Includes progress percentage for ring update

4. Added Echo listener to pick.blade.php
   - Subscribes to picking-ticket channel on page load
   - Updates Alpine store when events received
   - Recalculates progress automatically

Tests (8 new tests, all passing):
- Unit: Event structure, channels, broadcast data
- Feature: Broadcasting on updates, multiple workers, progress calculation
- Total: 75 tests passing (up from 67)

Minimal Complexity:
- ~120 lines of new code
- Follows existing cart broadcasting pattern
- No UI changes (uses existing Alpine reactivity)
- No database changes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 13:06:33 -07:00
Jon Leopard
ce6667a54d test(orders): add comprehensive test suite for payment term surcharge feature
Add 15 new tests covering unit tests, feature tests for order calculations,
and display tests for buyer/seller views.

Unit Tests (5 tests):
- Test Order::getSurchargePercentage() returns correct rates for all payment terms
- Test COD (0%), Net 15 (5%), Net 30 (10%)
- Test unknown payment terms default to 0%
- Test empty string handling

Feature Tests - Order Calculations (6 tests):
- Test surcharge storage for COD, Net 15, and Net 30 payment terms
- Test surcharge field is cast to decimal with 2 places
- Test tax is calculated on (subtotal + surcharge), not just subtotal
- Test total equals subtotal + surcharge + tax

Feature Tests - Display (4 tests):
- Test buyer order page displays surcharge when > 0
- Test buyer order page hides surcharge when = 0
- Test seller order page displays surcharge when > 0
- Test invoice page displays surcharge from order relationship

All 67 tests pass, including 15 new surcharge tests.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 12:53:16 -07:00
Jon Leopard
2c3fa7680d feat(orders): implement payment terms surcharge as separate line item
Add comprehensive surcharge tracking across the order and invoice workflow
to provide transparency and proper financial reporting for payment term fees.

Changes:
- Add surcharge column to orders table (decimal 10,2)
- Update Order model with surcharge field and getSurchargePercentage() helper
- Modify CheckoutController to calculate and store surcharge separately
- Display surcharge in buyer/seller order detail views
- Display surcharge in buyer/seller invoice views (from order relationship)
- Update DevSeeder with accurate surcharge calculations for test data

Surcharge rates by payment terms:
- COD: 0%
- Net 15: 5%
- Net 30: 10%
- Net 60: 15%
- Net 90: 20%

Financial calculation order:
1. Subtotal (sum of order items)
2. Surcharge (subtotal × payment term percentage)
3. Tax (subtotal + surcharge) × tax rate
4. Total (subtotal + surcharge + tax)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 12:22:30 -07:00
Jon Leopard
79b4212efd refactor(orders): remove 'To:' prefix from delivery location display
Simplified the delivery location display by removing the 'To:' label,
making it cleaner and more concise while still being clear from context.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 12:04:42 -07:00
Jon Leopard
1360468a48 refactor(orders): improve fulfillment method formatting in buyer order details
Cleaned up the fulfillment method display to improve readability:
- Icon and method type (Delivery/Pickup) now display together on one line
- Removed awkward colon after method type
- Location/driver details display below with proper prefix ("To:" or "Driver:")
- Better visual hierarchy with proper font sizing and spacing

Before: [icon] "Delivery: Location" (awkward wrapping)
After:  [icon] "Delivery" with "To: Location" below (clean, organized)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 12:03:46 -07:00
Jon Leopard
58eb8bf636 fix(orders): correctly access seller business through order items relationship
Fixed null pointer error in buyer order show view where $order->seller was
accessed but this relationship doesn't exist on the Order model.

The correct relationship chain is: Order → items → product → brand → business
where the business belongs to the brand is the seller.

Added proper null-safe navigation and checks to get seller business from:
$order->items->first()?->product?->brand?->business

This fixes the "Attempt to read property 'name' on null" error when viewing
buyer order details.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 12:00:03 -07:00
Jon Leopard
9bddad6725 refactor(orders): reorganize order detail panels for logical information grouping
Reorganized buyer and seller order detail pages to eliminate duplication and
group information logically by purpose:
- Company Information: WHO (parties, contacts, locations)
- Order Details: LOGISTICS (status, fulfillment method, workflow)
- Order Summary: FINANCIAL (amounts, payment terms, due dates)

Changes made:
- Buyer order show:
  - Added seller contact information to Company Information panel
  - Removed payment terms and due date from Order Details (moved to Order Summary)
  - Removed delivery method from Order Summary (logistics, not financial)

- Seller order show:
  - Removed payment terms and due date from Order Details (moved to Order Summary)
  - Reorganized Order Summary to show: Subtotal → Total → Payment Terms → Payment Due
  - Eliminated duplicate payment terms that appeared in middle of summary

This improves UX by presenting information in a clear, non-redundant structure
that matches user mental models for order details.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 11:58:02 -07:00
Jon Leopard
a5f4d99046 feat(orders): enhance buyer order summary with payment and delivery details
Added essential order information to buyer order summary card:
- Payment Terms (NET 30, COD, etc.)
- Payment Due Date (conditional display)
- Delivery Method (Pickup or Delivery)

Provides buyers with critical financial and logistics information at a glance.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 11:48:30 -07:00
Jon Leopard
1925055d2a refactor(orders/invoices): remove tax display from order and invoice summaries
B2B cannabis sales are tax-exempt, so tax field no longer needed in UI.

Changes:
- Removed tax line from seller order summary
- Removed tax line from buyer order summary
- Removed tax line from seller invoice summary and table footer
- Removed tax line from buyer invoice summary and table footer
- Simplified calculateTotal() JavaScript functions (removed calculateTax())
- Added comment: "B2B cannabis sales are tax-exempt"

Consistent with cart page changes from previous session.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 11:45:40 -07:00
Jon Leopard
35db3a5f34 fix(routes): add custom route model binding for picking ticket numbers
- Added Route::bind for 'pickingTicket' parameter in AppServiceProvider
- Resolves Order models by picking_ticket_number column
- Fixes 404 errors when accessing /s/{business}/pick/PT-XXXXX URLs
- Required after standardizing picking ticket storage with PT- prefix

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 11:40:25 -07:00
Jon Leopard
19668e7550 refactor(orders): standardize picking ticket number storage with PT- prefix
Fixes inconsistency where picking tickets stored raw codes while orders/invoices
stored prefixed identifiers. Now all prefixed identifiers follow same pattern.

Changes:
- Update generatePickingTicketNumber() in Order.php and InvoiceService.php to return PT- prefixed codes
- Remove formatted_picking_ticket accessor (no longer needed)
- Update all views to use picking_ticket_number directly
- Update DevSeeder to seed PT- prefixed codes
- Run migrate:fresh --seed to apply changes

Before:
- Database: "D3E4F" (raw)
- Display: "PT-D3E4F" (accessor adds prefix)

After:
- Database: "PT-D3E4F" (prefixed)
- Display: "PT-D3E4F" (stored as-is, matches ORD-/INV- pattern)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 11:25:05 -07:00
Jon Leopard
1f477d388e refactor(invoices): remove order number column from buyer invoices table
- Removed "Order Number" header from table
- Removed order number cell displaying order link
- Updated empty state colspan from 8 to 7 columns
- Keeps invoice table focused on invoice-specific information

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 11:18:09 -07:00
Jon Leopard
8b249a8b4a fix(orders): resolve duplicate PT- prefix in picking ticket numbers
- Remove PT- prefix from DevSeeder picking ticket data (store raw codes)
- Database should store raw codes (e.g., "D3E4F") not prefixed codes
- formatted_picking_ticket accessor adds PT- prefix for display
- Fixes PT-PT-D3E4F displaying as PT-D3E4F correctly
- Updated banner layout: removed progress circle, moved button right

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 11:17:20 -07:00
Jon Leopard
2982eb085b fix(orders): improve picking ticket banner layout and fix duplicate prefix
- Remove radial progress circle from banner
- Move "Open Picking Ticket" button to right side using justify-between
- Fix duplicate "PT-" prefix (was showing "PT-PT-D3E4F")
- Now uses formatted_picking_ticket accessor instead of manually adding prefix

The formatted_picking_ticket accessor already adds "PT-" prefix, so we
don't need to add it again in the route. This prevents the duplicate.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 11:11:00 -07:00
Jon Leopard
e341627aa1 fix(orders): move modals outside tbody to fix zebra striping
- Create new partial: seller/orders/partials/order-modals.blade.php
- Move Accept, Reject, Cancel modals + scripts outside table structure
- Fix invalid HTML: dialog/script elements cannot be direct children of tbody
- Table zebra striping now works correctly

The issue was that modal dialogs and scripts were placed inside <tbody>
before the <tr> elements, breaking the table structure and preventing
DaisyUI's table-zebra class from applying alternating row colors.

Now renders as:
  <table>
    <tbody>
      <tr>...</tr>  <!-- Clean structure -->
    </tbody>
  </table>
  <!-- Modals outside table -->

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 11:05:30 -07:00
Jon Leopard
0855fef884 fix(orders): add p-0 to card-body for proper table zebra striping
- Add p-0 class to remove card-body padding
- Allows table-zebra styling to render correctly
- Matches pattern used in other table views

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 11:02:00 -07:00
Jon Leopard
9207b3ebbe fix(orders): remove hover class to allow zebra striping
- Remove class="hover" from table rows
- Allows table-zebra striping to display properly
- Table rows still have hover effect from DaisyUI defaults

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 10:59:38 -07:00
Jon Leopard
1fae0c6426 style(tables): apply zebra striping to all seller dashboard tables
- Add table-zebra class to all main seller tables
- Add w-full for consistent table width
- Applied to: invoices, orders, dashboard, manifests, BOM
- Matches buyer dashboard table styling pattern

Tables updated:
  - seller/invoices/index.blade.php
  - seller/invoices/create.blade.php
  - seller/invoices/show.blade.php
  - seller/orders/index.blade.php
  - seller/orders/show.blade.php
  - seller/orders/manifest/show.blade.php
  - seller/dashboard.blade.php
  - seller/products/bom/index.blade.php

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 10:58:34 -07:00
Jon Leopard
51f96bdc04 refactor(invoices): remove order number column from seller invoices table
- Remove Order Number column from table header
- Remove order number cell from table body
- Update empty state colspan from 8 to 7
- Simplifies invoice table by removing redundant order reference

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 10:53:45 -07:00
Jon Leopard
72dd0bcebd fix(ui): improve date column spacing and standardize badge sizes
- Add mt-0.5 spacing between date and time in orders table
- Restructure date column to prevent "smooshed" appearance
- Add badge-sm to status badges to match fulfillment badge size
- Ensures consistent badge sizing across Status and Fulfillment columns

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 10:51:07 -07:00
Jon Leopard
eb9f737bbe fix(ui): standardize order number styling across tables
- Change "Order ID" → "Order Number" in dashboard table
- Change "Order #" → "Order Number" in orders table
- Apply consistent font-mono text-sm styling to order numbers
- Match styling with buyer invoices table standard

All order/invoice numbers now use:
  - Table cell: font-mono text-sm
  - Link: text-primary hover:underline

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 10:48:02 -07:00
Jon Leopard
d3ea27dff2 refactor(views): standardize dashboard view naming convention
- Delete orphaned seller/dashboard.blade.php
- Rename dashboard/nexus.blade.php → seller/dashboard.blade.php
- Update DashboardController to use seller.dashboard view
- Remove empty dashboard/ directory

This aligns seller dashboard naming with buyer dashboard pattern:
  - Buyer: buyer/dashboard.blade.php
  - Seller: seller/dashboard.blade.php

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 10:38:16 -07:00
Jon Leopard
5e8c987edb fix(env): add Reverb credentials to .env.example for local development
Populate REVERB_APP_ID, REVERB_APP_KEY, REVERB_APP_SECRET, and
VITE_REVERB_APP_KEY with working local development credentials to match
phpunit.xml configuration.

This follows Laravel's standard convention of including working credentials
for self-hosted local services (like DB_PASSWORD=password) so that
`cp .env.example .env && sail up` works immediately without manual
configuration.

These credentials are safe to commit as they only work against local Docker
Reverb containers and match the test environment configuration.

Fixes issue where developers had to manually copy Reverb credentials from
phpunit.xml to get tests passing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 10:34:57 -07:00
Jon Leopard
257e819e75 fix(nexus-dashboard): remove compliance status and fix inventory link
- Remove Compliance Status card (redundant for MVP)
- Fix "View all inventory" link to use correct business-scoped route
- Fix Quick Actions links to use business-scoped routes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 09:55:10 -07:00
Jon Leopard
aa95ad4e47 fix(nexus-dashboard): fix Quick Actions links and remove unimplemented feature
- Fix 'Add New Product' link to use seller.business.products.create route
- Fix 'View All Orders' link to use seller.business.orders.index route
- Remove 'Manage Customers' button (feature not yet implemented)
- All Quick Action buttons now work correctly with business-scoped routes

Note: This is the dashboard at /s/{business}/dashboard (not /s/dashboard)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 09:45:26 -07:00
Jon Leopard
06e9402719 fix(brand-switcher): remove redundant success flash messages
- Remove 'Switched to [brand]' flash message on brand switch
- Remove 'Viewing all brands' flash message
- Brand context is already visible beneath the brand selector
- Reduces UI noise and visual clutter

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 09:42:18 -07:00
Jon Leopard
43883f5b36 fix(seller-dashboard): remove compliance status and fix navigation links
- Remove Compliance Status card (not yet implemented functionality)
- Add 'Add New Product' button to Quick Actions
- Fix 'View all inventory' link to use business-scoped route
- All Quick Action buttons now work with correct routes

Changes:
- Removed lines 260-282 (Compliance Status card)
- Added seller.business.products.create link with icon
- Fixed seller.products.index → seller.business.products.index

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 09:38:09 -07:00
Jon Leopard
06a004bc96 fix(docker): pass VITE_REVERB build args to enable frontend WebSocket
- Add VITE_REVERB_* variables as build arguments in Dockerfile
- Pass build args in CI/CD pipeline for dev environment
- Vite env vars are build-time, not runtime - must be available during npm build
- Fixes 'You must pass your app key when you instantiate Pusher' error

Technical details:
- Vite inlines import.meta.env.VITE_* values during build (npm run build)
- Adding env vars to K8s ConfigMap/Secrets doesn't help - they need to be present during Docker image build
- Solution: Accept as ARG in Dockerfile, export as ENV for Vite, pass from CI/CD pipeline

Next step: Add 'reverb_app_key_dev' secret to Woodpecker CI with value:
6VDQTxU0fknXHCgKOI906Py03abktP8GatzNw3DvJkU=

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 09:22:32 -07:00
Jon Leopard
48060b2c18 fix(sidebars): remove x-cloak to show navigation items
- Remove x-cloak from both buyer and seller sidebars
- x-cloak was hiding entire menu until Alpine.js initializes
- If Alpine has any initialization delay or error, menu stays hidden
- State persistence via $persist() still works without x-cloak
- Reverts commit 0178fe6 which incorrectly added x-cloak to buyer sidebar

Issue: Seller sidebar showing no nav items on dev.cannabrands.app
Root cause: x-cloak hiding menu before Alpine.js initialization completes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 02:54:41 -07:00
Jon Leopard
0178fe6a75 fix(buyer-sidebar): add x-cloak to enable state persistence
- Add x-cloak attribute to buyer sidebar menu state wrapper
- Ensures Alpine.js properly hydrates localStorage state before render
- Matches seller sidebar implementation for consistency
- Fixes sidebar menu expansion state not persisting across page reloads

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 02:51:41 -07:00
Jon Leopard
7d4babc23a fix(k8s): add VITE_REVERB_* environment variables for frontend
Added VITE_REVERB_* variables so the frontend JavaScript can
access the Reverb configuration. Fixes 'You must pass your app
key when you instantiate Pusher' error in browser console.
2025-10-29 02:42:11 -07:00
Jon Leopard
39708e31d8 fix(tests): add comprehensive home page redirect tests
Added tests that would have caught the buyer.marketplace.index route error:
- Test authenticated buyer redirect to marketplace
- Test authenticated seller redirect to dashboard
- Test guest redirect to registration

Also removed broken is_super_admin check from home route (admin
functionality not implemented yet).
2025-10-29 02:28:35 -07:00
Jon Leopard
439d5e4d90 fix(branding): update CI/CD pipeline header to 'Cannabrands Hub' 2025-10-29 02:25:01 -07:00
Jon Leopard
12ad2254c9 fix(branding): update app name from 'Cannabrands CRM' to 'Cannabrands Hub'
Updated in:
- k8s/base/configmap.yaml (APP_NAME)
- .woodpecker/.ci.yml (comment header)
2025-10-29 02:24:47 -07:00
Jon Leopard
8801ec59b0 fix(routes): use correct route name for buyer redirect
Changed from buyer.marketplace.index to buyer.browse, which is
the actual route name defined for the marketplace page.
2025-10-29 02:23:46 -07:00
Jon Leopard
8e232284a8 fix(k8s): remove health checks from Reverb deployment
Reverb doesn't provide a /health endpoint by default, causing
continuous restart loops. Monitor via logs and pod status instead.
2025-10-29 02:13:22 -07:00
Jon Leopard
5c8b006ce6 fix(k8s): use :dev image tag for Reverb in dev environment 2025-10-29 02:08:09 -07:00
Jon Leopard
0453d90aa7 fix(k8s): add Reverb configuration to dev overlay
- Add REVERB_HOST override for cannabrands-dev namespace
- Add /app WebSocket path to ingress patch for Reverb routing
- Ensures Reverb service is properly accessible in dev environment
2025-10-29 02:06:37 -07:00
Jon Leopard
751c452d6a chore: add secrets files to gitignore
Prevent accidental commit of local secrets backup files.
Secrets are stored securely in Kubernetes only.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 02:06:20 -07:00
Jon Leopard
863f0ba162 feat(k8s): add Reverb WebSocket server deployment
Add Kubernetes manifests for Laravel Reverb real-time broadcasting:

**New Resources:**
- `reverb-deployment.yaml` - Reverb WebSocket server (2 replicas)
- `REVERB_DEPLOYMENT.md` - Complete deployment documentation

**Configuration Updates:**
- `configmap.yaml` - Added Reverb environment variables
- `ingress.yaml` - Added /app/* path for WebSocket connections
- `kustomization.yaml` - Include new Reverb deployment

**Features:**
- High availability with 2 replicas
- Health checks on /health endpoint
- WebSocket timeout support (3600s)
- Resource limits (512Mi memory, 250m CPU)
- Cluster-internal service discovery

**Deployment:**
Reverb connects to Redis for pub/sub and exposes port 8080 for
WebSocket connections. Ingress routes /app/* to Reverb service
with extended timeouts for long-lived connections.

Requires `REVERB_APP_KEY` and `REVERB_APP_SECRET` in Kubernetes
secrets before deployment.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 02:06:20 -07:00
Jon
77fb5f9b30 Merge pull request 'Real-time Cart Broadcasting with Optimistic UI Updates' (#15) from feature/real-time-cart-broadcasting into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/15
2025-10-29 08:39:36 +00:00
Jon Leopard
9baf1e2a88 feat(branding): use official logo across all authentication pages
Replace icon+text branding with official Cannabrands SVG logo on:
- Login page
- Registration pages (landing, buyer, seller)
- Password reset/forgot pages
- Email verification page
- Registration completion pages

All authentication pages now display consistent branding using
images/canna_white.svg for professional, unified user experience.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 01:28:57 -07:00
Jon Leopard
ee7fee23fd fix(tests): update home page test for redirect behavior
Update ExampleTest to expect redirect to /register instead of 200 OK
since unauthenticated users are now redirected to registration page.

Also update registration page to use official Cannabrands SVG logo
instead of icon + text for consistent branding across the app.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 01:25:01 -07:00
Jon Leopard
2ce56f59c5 feat(routes): redirect home page to registration for guests
Update home page (/) to redirect unauthenticated users to /register
since there's no public-facing homepage yet.

**Behavior:**
- Guests → /register (account selection page)
- Buyers → /b/marketplace
- Sellers → /s/dashboard
- Admins → /admin

This provides a cleaner UX by immediately directing visitors to the
appropriate entry point based on their authentication status.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 01:22:09 -07:00
Jon Leopard
dec859b486 feat(ci): add Reverb server to test environment
Configure Reverb WebSocket server in both local and CI test environments
to properly test real-time broadcasting and channel authorization.

**Local (Docker Sail):**
- phpunit.xml connects to existing `reverb` service
- Uses production Reverb credentials from running container

**CI/CD (Woodpecker):**
- Added Redis service for Reverb pub/sub
- Reverb starts in background before tests (localhost:8080)
- Environment variables override phpunit.xml defaults

This enables full integration testing of:
- WebSocket channel authorization (security)
- Real-time cart update broadcasting
- Multi-user channel isolation

All 50 tests now pass with proper Reverb integration.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 01:18:03 -07:00
Jon Leopard
f80d018de7 fix(tests): add socket_id parameter to broadcasting auth tests
Fix CartChannelAuthorizationTest by adding required socket_id parameter
to all test cases. Pusher/Reverb requires socket IDs in format "1234.5678"
(two numbers separated by period) for channel authorization.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 01:04:17 -07:00
Jon Leopard
4c548fec6f feat(cart): add optimistic UI updates with debouncing
Cart Page:
- Instant quantity updates with 300ms debouncing
- Optimistic total calculations (subtotal/total update immediately)
- Subtle opacity fade during background sync
- Eliminates race conditions from rapid clicking

Marketplace Page:
- Instant "Add to Cart" feedback
- Optimistic quantity updates with debouncing
- Automatic rollback on server errors

Cart Badge:
- Receives count directly from events (no fetch needed)
- 50% reduction in HTTP requests per cart operation

Tax Display:
- Hidden tax line on cart and checkout pages
- All transactions are B2B wholesale (tax-exempt with resale certificate)
- Backend tax logic preserved for future use

Performance: Reduces perceived latency to 0ms for all cart operations.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 00:57:41 -07:00
Jon Leopard
b74e7f9e49 docs: add real-time features documentation
- Add comprehensive guide for Laravel Reverb implementation
- Document WebSocket architecture and configuration
- Include troubleshooting guide for common issues
- Add examples of real-time features (cart updates, notifications)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 00:11:16 -07:00
Jon Leopard
dbdd002906 test(broadcasting): add comprehensive tests for cart broadcasting
- Add CartBroadcastingTest for testing broadcast event dispatch
- Add CartUpdatedEventTest for testing event structure
- Add CartChannelAuthorizationTest for testing channel authorization
- Add ProductFactory and BrandFactory for test data generation
- Fix enum constraint (use 'pre_roll' instead of 'pre-roll')
- Use quantity_on_hand/quantity_allocated instead of available_quantity

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 00:11:09 -07:00
Jon Leopard
b2a6b2a709 feat(broadcasting): add Laravel Reverb configuration and private channel auth
- Add Reverb config for WebSocket server
- Add broadcasting config with Reverb connection
- Configure private channel authorization for user-specific broadcasts
- Register channel routes in bootstrap/app.php

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 00:07:44 -07:00
Jon Leopard
afb5e5293a fix(seeder): set business_onboarding_completed for fully onboarded users
Problem: DevSeeder created businesses with onboarding_completed=true,
but didn't set the User model's business_onboarding_completed flag.

This caused the "Complete Your Business Profile" banner to incorrectly
display on buyer/seller dashboards even for fully onboarded test accounts.

Solution: Added business_onboarding_completed => true to buyer and seller
user creation in DevSeeder.

Impact:
- buyer@example.com: Banner will NOT show (fully onboarded)
- seller@example.com: Banner will NOT show (fully onboarded)
- pending-buyer@example.com: Banner WILL show (intentionally incomplete)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-28 21:16:14 -07:00
Jon Leopard
34197a9551 security(telescope): restrict access to Super Admins only
Changes:
- Gate now requires 'Super Admin' role instead of any authenticated user
- Middleware requires authorization in all environments except local
- Localhost (APP_ENV=local) remains open for dev convenience
- dev.cannabrands.app (APP_ENV=development) now requires Super Admin login

Security Impact:
- Before: Anyone could access /telescope on dev.cannabrands.app
- After: Only authenticated Super Admins can access Telescope

Also fixed:
- Increased picking_ticket_number column from varchar(5) to varchar(20)
  to accommodate ticket format (PT-XXXXX)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-28 20:35:39 -07:00
Jon Leopard
b54edcbfcf feat: add BusinessUser pivot model with proper permission casting
- Create custom BusinessUser pivot model to handle business-user relationships
- Add proper array casting for permissions field (was causing count() errors)
- Add boolean casting for is_primary field
- Update Business and User models to use custom pivot via ->using()
- Fixes "count(): Argument #1 must be of type Countable|array, string given" error

This resolves issues where permissions stored as JSON strings weren't being
properly cast to arrays for display and manipulation in the UI.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-28 20:30:25 -07:00
Jon Leopard
adfaa0f088 fix(telescope): remove authorization requirement in development environment
- Conditionally exclude Authorize middleware when APP_ENV=development
- Allows unauthenticated access to /telescope in dev environment
- Staging/production environments still require authentication
- Cleaner approach using config file vs runtime modification
2025-10-28 16:44:35 -07:00
Jon Leopard
b129e8a1f7 feat(telescope): allow unauthenticated access in development environment
- Remove authentication requirement for Telescope in local/development
- Makes debugging performance issues easier on dev.cannabrands.app
- Staging/production still require authentication
2025-10-28 16:29:50 -07:00
Jon Leopard
6bed3500e6 fix(ci): use dictionary format for build_args per Woodpecker spec
- build_args requires YAML map/dictionary format, not array
- Was generating: --build-arg *=GIT_COMMIT_SHA=...,APP_VERSION=...
- Now generates: --build-arg GIT_COMMIT_SHA=... --build-arg APP_VERSION=...
- Fixes version.env showing 'unknown' instead of actual commit SHA
- Applied to dev, staging, and production builds
2025-10-28 16:11:25 -07:00
Jon Leopard
75fcd4842f fix(ci): use cache_images instead of cache_from/cache_to for cleaner config
- Woodpecker docker-buildx provides cache_images setting specifically for this
- Avoids comma escaping issues with cache_from/cache_to parameters
- Automatically configures with mode=max, image-manifest=true, oci-mediatypes=true
- Simpler and more maintainable than manual cache parameter escaping
- Applied to dev, staging, and production builds
2025-10-28 15:33:47 -07:00
Jon Leopard
a516a1cd3c fix(ci): escape commas in buildcache parameters per Woodpecker requirements
- Woodpecker docker-buildx plugin requires commas to be escaped with \\,
- Changed cache_from to array format with escaped commas
- Changed cache_to to use escaped commas: type=registry\\,ref=...\\,mode=max
- Follows Woodpecker community conventions and best practices
- Ref: https://woodpecker-plugins.geekdocs.de/plugins/wp-docker-buildx/
2025-10-28 15:20:57 -07:00
Jon Leopard
8aa87e4464 fix(ci): quote buildcache parameters for Woodpecker docker-buildx plugin
- Woodpecker plugin requires cache_from/cache_to to be quoted strings
- Previous unquoted format was incorrectly parsed as separate arguments
- Fixes: ERROR: type required form> "ref=code.cannabrands.app..."
2025-10-28 14:58:08 -07:00
Jon Leopard
9634985a3a perf(ci): add Docker buildcache to improve build times from 7-9min to 2-4min
- Add cache_from/cache_to for dev, staging, and production builds
- Use registry-backed buildcache with mode=max for maximum layer reuse
- Expected 50-70% reduction in subsequent build times
- Industry standard practice for CI/CD Docker builds
2025-10-28 14:53:14 -07:00
Jon Leopard
2bf5acc257 perf: cache navigation badges in ComponentResource and UserResource 2025-10-28 14:32:30 -07:00
Jon Leopard
34dfdb437b perf: fix N+1 query in OrderResource (business) and cache badge 2025-10-28 14:32:02 -07:00
Jon Leopard
26d2718b23 perf: fix N+1 queries in InvoiceResource (order, business) and cache badge 2025-10-28 14:31:25 -07:00
Jon Leopard
1dd828bc12 perf: fix N+1 queries in ProductResource (brand, strain) and cache badge 2025-10-28 14:30:48 -07:00
Jon Leopard
4dbee9ed64 feat: add version/SHA display to Filament admin footer 2025-10-28 14:28:23 -07:00
Jon Leopard
51979b23fa fix(ci): properly quote build_args to fix version detection (sha-unknown issue) 2025-10-28 14:27:41 -07:00
Jon Leopard
dc29cf58bd perf: fix N+1 queries in BrandResource with eager loading and badge caching 2025-10-28 14:21:07 -07:00
Jon Leopard
c478666fb9 fix(ci): strip all whitespace from kubeconfig secret, not just newlines 2025-10-28 13:08:14 -07:00
Jon Leopard
b6045062cb fix(ci): strip newlines from kubeconfig before base64 decode 2025-10-28 13:05:20 -07:00
Jon Leopard
2dff75e209 ci: trigger build to test ServiceAccount auto-deploy 2025-10-28 12:52:37 -07:00
Jon Leopard
e1d4468667 perf: fix N+1 queries in BusinessResource with eager loading
- Add modifyQueryUsing() to eager-load owner and users relationships
- Cache navigation badge query for 60 seconds to avoid DB hit on every page
- Reduces queries from 7 to 3 for business list
- Expected improvement: 1,400ms -> ~600ms for 3 businesses
2025-10-28 12:34:04 -07:00
Jon Leopard
a4b88abdaa fix: temporarily allow all users to access Telescope for debugging performance 2025-10-28 12:03:22 -07:00
Jon Leopard
58a1b6099f ci: trigger build 2025-10-28 11:39:01 -07:00
Jon Leopard
d3f17ce805 ci: retry with package permissions enabled 2025-10-28 11:28:55 -07:00
Jon Leopard
60bf5ac8c5 ci: retry with updated gitea_token 2025-10-28 11:16:26 -07:00
Jon Leopard
23e6cf1cff ci: retry with fixed kubeconfig secret 2025-10-28 11:06:29 -07:00
Jon Leopard
865ec44b33 ci: retry with configured secrets 2025-10-28 10:48:26 -07:00
Jon Leopard
01e9a4df0a ci: rebuild with cache enabled 2025-10-28 10:38:56 -07:00
Jon Leopard
9515901506 ci: trigger build for performance fixes (bedae4f) 2025-10-28 10:34:49 -07:00
Jon Leopard
bedae4fc85 perf(postgres): eliminate CPU throttling and SSL overhead
Root Cause: PostgreSQL connection time 115ms due to:
1. CPU throttling (7.6% of time) during scram-sha-256 auth
2. SSL handshake overhead (14ms) for in-cluster traffic

Changes:
- Increase postgres CPU limit: 1 core → 2 cores (eliminates throttling)
- Increase postgres CPU request: 250m → 500m
- Disable SSL for in-cluster: sslmode='prefer' → 'disable' (env configurable)

Expected Impact:
- Connection time: 115ms → ~20ms (83% faster)
- Zero CPU throttling
- Maintains persistent connections for long-lived workers

Testing showed:
- Direct postgres query: 3.5ms (healthy)
- TCP connection: 1ms (healthy)
- PostgreSQL handshake: 101ms (throttled) → ~6ms (after fix)
- SSL overhead: 14ms → 0ms

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 23:24:22 -07:00
Jon Leopard
704ced2366 perf(database): enable persistent PostgreSQL connections
Root Cause Analysis:
- Direct postgres query: 3.5ms 
- Laravel first query: 203ms  (connection overhead)
- Laravel subsequent queries: 6ms  (reusing connection)

Problem: Every page load creates a new database connection (200ms overhead)

Solution: Enable PDO persistent connections to reuse connections across
requests within the same PHP-FPM worker process.

Expected Impact: Reduce page load times from 10-13 seconds to < 1 second

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 23:10:26 -07:00
Jon Leopard
44468532b6 feat(k8s): migrate Docker registry to code.cannabrands.app
Complete registry domain migration from .com to .app:
- Updated dev deployment to pull from code.cannabrands.app
- Updated staging deployment to pull from code.cannabrands.app
- Updated production deployment to pull from code.cannabrands.app

All environments now use the new .app registry domain.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 22:56:34 -07:00
Jon Leopard
7ef12da2fc fix(docker): add intl PHP extension for Filament number formatting
Added intl extension to resolve RuntimeException when using Laravel's
Number::format() method in Filament tables.

Changes:
- Added icu-dev and icu-data-full to system dependencies
- Added intl to PHP extensions installation

Fixes: "The 'intl' PHP extension is required to use the [format] method"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 22:37:11 -07:00
Jon Leopard
289d611ac3 docs: update all documentation with new cannabrands.app domains
Updated domain references across all documentation:
- dev.cannabrands.com → dev.cannabrands.app
- staging.cannabrands.com → staging.cannabrands.app
- hub.cannabrands.com → cannabrands.app
- code.cannabrands.com → code.cannabrands.app (Gitea)
- ci.cannabrands.com → ci.cannabrands.app (Woodpecker)
- k8s.cannabrands.com → k8s.cannabrands.app (Rancher/K8s API)
- rancher.cannabrands.com → rancher.cannabrands.app

Files updated:
- All .woodpecker/*.md documentation
- All docs/*.md files
- CONTRIBUTING.md, CHANGELOG.md, NOTIFICATIONS.md
- k8s/KUBECTL_COMMANDS.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 22:05:08 -07:00
Jon Leopard
1568f1da2e refactor(infra): migrate domains from cannabrands.com to cannabrands.app
Domain updates:
- dev.cannabrands.com → dev.cannabrands.app
- staging.cannabrands.com → staging.cannabrands.app
- hub.cannabrands.com → cannabrands.app (production)
- code.cannabrands.com → code.cannabrands.app (Docker registry)

Changes:
- Updated all K8s overlays (dev/staging/production) with new domains
- Updated ingress TLS hostnames and APP_URL environment variables
- Updated CI/CD pipeline Docker registry and deployment URLs
- Updated base deployment image references
- Updated Docker registry secret creation example

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 21:35:22 -07:00
Jon Leopard
83411b429f fix(nginx): route Livewire dynamic assets to PHP-FPM instead of static file handler
Root cause: Nginx was matching /livewire/livewire.js with the static file cache block,
looking for it on disk, and returning 404 before Laravel could handle the request.

Solution: Add specific location block for /livewire/*.js routes BEFORE the static assets
block to ensure these dynamic routes reach PHP-FPM/Laravel.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 21:07:05 -07:00
Jon Leopard
0d1262e224 fix(k8s): add asset publishing to dev deployment overlay
Update dev deployment patch to include full init container spec with asset publishing commands.
This ensures the dev environment publishes Filament assets on every deployment.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 20:47:47 -07:00
Jon Leopard
0d696435e7 fix(k8s): add Filament asset publishing to init container and update both containers in CI/CD
Root cause: Livewire.js and Filament assets were not being published, causing login failures.

Changes:
- Add vendor:publish and filament:assets commands to init container
- Reorder init container commands: publish assets before migrations
- Add echo statements for better visibility in init container logs
- Update CI/CD to update both 'app' and 'migrate' containers with SHA-tagged images

This ensures Filament assets are available at runtime and both containers use the same image version.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 20:31:39 -07:00
Jon Leopard
1cef063a6c fix: move asset publishing to runtime (init container)
Root cause: php artisan commands require environment variables and Laravel
bootstrap, which aren't available at Docker build time.

Corrected approach (Laravel best practice):
- Runtime (init container): Publish assets with full env context
- Sequence: vendor:publish → filament:assets → optimize → migrate

Changes:
- Removed artisan commands from Dockerfile (can't run without env)
- Added vendor:publish + filament:assets to init container
- Kept optimize commands for runtime caching

Why this is correct:
- Artisan needs APP_KEY, database config, etc.
- Init container has full environment variables
- Slightly slower startup but guaranteed to work
- Standard Laravel Docker deployment pattern

Previous attempt: Build-time publishing failed because Laravel
couldn't bootstrap without environment configuration.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 20:01:01 -07:00
Jon Leopard
185790d566 refactor: optimize asset publishing with build-time + runtime approach
Implements industry-standard two-phase optimization following Laravel
official documentation and Filament deployment best practices.

Build Time (Dockerfile):
- php artisan vendor:publish --tag=public --force
  * Publishes static assets from vendor packages
  * Laravel official docs: "users will typically need to overwrite
    the assets every time the package is updated"
- php artisan filament:assets
  * Publishes Filament/Livewire JavaScript to /public
  * Baked into Docker image for immutable deployments

Runtime (Init Container):
- php artisan optimize
  * Laravel official: caches config, routes, views, events
  * Uses actual runtime environment variables
- php artisan filament:optimize
  * Caches Filament components and Blade icons
  * Respects environment-specific configuration

Benefits:
- Faster pod startup (assets pre-published in image)
- Immutable containers (assets don't change at runtime)
- Proper cache optimization with runtime config
- Follows Laravel + Filament official recommendations

References:
- https://laravel.com/docs/12.x/deployment
- https://laravel.com/docs/12.x/packages#publishing-file-groups
- https://filamentphp.com/docs/4.x/deployment

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 19:52:09 -07:00
Jon Leopard
1a4ea3f601 fix(filament): publish Filament/Livewire assets in init container
Root cause: Livewire.js was returning 404, preventing Filament login form
from submitting properly. The form would reload instead of authenticating.

Changes:
- Added 'php artisan filament:assets' to init container
- This publishes Livewire and Filament JavaScript assets to /public
- Runs before migrations during pod startup

Fixes:
- Livewire.js now available at /livewire/livewire.js
- Filament login form can now submit properly
- Interactive components work correctly

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 17:58:32 -07:00
Jon Leopard
2a14e026ec fix(auth): configure trusted proxies for K8s ingress to enable login
Root cause: Laravel wasn't trusting the K8s ingress proxy, causing it to
incorrectly detect HTTP instead of HTTPS. This prevented secure session
cookies from being set, breaking authentication.

Changes:
- Added trustProxies configuration to bootstrap/app.php
- Trust all proxies ('*') for K8s environments
- Configure all X-Forwarded-* headers for correct HTTPS detection

Also advances Redis adoption to Phase 2:
- SESSION_DRIVER=redis (was database, but table didn't exist)
- SESSION_SECURE_COOKIE=true (required for HTTPS environments)
- Keeps QUEUE_CONNECTION=database for Phase 3

Fixes:
- Admin login now works at /admin
- Secure cookies properly set over HTTPS
- Session persistence across requests

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 17:34:52 -07:00
Jon Leopard
6e6c44d070 fix(ci): add watch permissions to service account for rollout status
The kubectl rollout status command needs 'watch' verb to monitor deployment progress.

Added 'watch' permission to:
- deployments (for rollout status)
- pods (for pod status monitoring)
- replicasets (for replica tracking)

This fixes the error:
'User "system:serviceaccount:cannabrands-dev:woodpecker-deployer"
cannot watch resource "deployments"'

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 16:40:38 -07:00
Jon Leopard
d169244c78 ci: test with service account token 2025-10-27 16:29:52 -07:00
Jon Leopard
5785f85de3 ci: test with service account token 2025-10-27 16:11:49 -07:00
Jon Leopard
301bb1668f feat(ci): add dedicated service account for CI/CD deployments
Following Kubernetes best practices for programmatic access:

**Why Service Accounts?**
- Industry standard for CI/CD (GitHub Actions, GitLab CI, ArgoCD all use this)
- Principle of least privilege (only deployment permissions)
- No expiration (unlike personal kubeconfig certificates)
- Clear audit trail (actions show as 'woodpecker-deployer' not a person)
- Survives team changes (not tied to individual developer)

**What Was Created:**
- ServiceAccount: woodpecker-deployer
- Role: Limited to deployments, pods, replicasets in cannabrands-dev
- RoleBinding: Connects service account to role
- Secret: Long-lived token for authentication
- Script: Generates minimal kubeconfig for Woodpecker

**Security:**
- Scoped to single namespace (cannabrands-dev only)
- Minimal permissions (can't create/delete, only update deployments)
- Easily revocable (kubectl delete secret woodpecker-deployer-token)

**Usage:**
1. kubectl apply -f k8s/ci-serviceaccount.yaml
2. ./k8s/generate-ci-kubeconfig.sh
3. Copy base64 output to Woodpecker kubeconfig_dev secret

**References:**
- https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/
- https://kubernetes.io/docs/reference/access-authn-authz/rbac/

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 16:06:30 -07:00
Jon Leopard
d6ac281b29 ci: trigger auto-deploy test with new pipeline config 2025-10-27 16:03:14 -07:00
Jon Leopard
58f15d0de7 docs(ci): add auto-deploy setup guide
Comprehensive guide for setting up and using auto-deploy to dev environment.

Includes:
- One-time Woodpecker secret setup
- How it works (image tagging, deployment flow)
- Rollback procedures
- Troubleshooting common issues
- Benefits and time savings

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 15:46:25 -07:00
Jon Leopard
18485193de feat(ci): implement auto-deploy to dev after successful builds
Following industry best practices (Option C + D):
- **Unique Image Tags**: dev-{SHA}, staging-{SHA}, {SHA}
- **Auto-Deploy Dev**: Pushes to develop → CI passes → Auto-deploy to K8s
- **Manual Staging/Prod**: Maintains safety gates for customer-facing environments

**Implementation:**
1. Added `dev-{SHA}` tag to builds for immutable artifact tracking
2. Added `deploy-dev` step that runs after successful CI build
3. Uses `kubectl set image` to update deployment with new SHA tag
4. Waits for rollout completion with health checks
5. Requires `kubeconfig_dev` secret in Woodpecker

**Workflow Alignment:**
-  Follows "Graduated Enforcement" philosophy (CONTRIBUTING.md)
-  Fast feedback for dev (unstable, daily integration)
-  Manual gates for staging/prod (stable, pre-production)
-  CI remains required gate (can't bypass)
-  Maintains cannabis compliance audit trails

**Benefits:**
- Developers get instant feedback on dev.cannabrands.com (~5.5min)
- No manual kubectl commands needed for dev deployments
- Unique tags enable easy rollbacks (kubectl set image to previous SHA)
- Staging/production remain manual for maximum safety

**Next Steps:**
1. Add `kubeconfig_dev` secret to Woodpecker (one-time setup)
2. Push to develop → Auto-deployment activates
3. Optionally add same for staging (if desired)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 15:43:34 -07:00
Jon Leopard
a28d5b534e feat: replace Laravel Debugbar with Telescope for K8s environments
Following industry best practices for debugging across environments:

**Laravel Telescope (K8s - All Environments):**
- Production-safe debugging tool with authentication
- Dev: Records all requests, queries, jobs (TELESCOPE_ENABLED=true)
- Staging/Prod: Records only exceptions/failures (with Super Admin auth)
- Accessible at /telescope endpoint
- Benefits: Debug production issues without SSH, monitor performance

**Laravel Debugbar (Local Sail Only):**
- Kept as dev dependency for local development
- Automatically enabled in APP_ENV=local
- Not included in Docker production builds
- Benefits: Fast local debugging with toolbar

**Configuration:**
- Updated TelescopeServiceProvider to support 'development' environment
- Added environment-aware access control (all users in dev, Super Admins in staging/prod)
- Added TELESCOPE_ENABLED to all K8s overlays (dev/staging/production)
- Removed DEBUGBAR_ENABLED from K8s dev (not needed in containers)

This follows the 12-Factor App principle: K8s dev mirrors production while
local Sail provides fast iteration with debugbar.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 15:29:55 -07:00
Jon Leopard
97bef45d4f Merge feature/add-redis-to-kubernetes into develop
- Add Redis infrastructure to all K8s environments (Phase 1: Cache only)
- Fix DevSeeder component types and picking ticket number constraints
- Add Laravel Debugbar for development environment
- Fix URL configurations (dev/staging/hub.cannabrands.com)

Includes comprehensive Redis documentation and phased adoption strategy.
2025-10-27 15:19:55 -07:00
Jon Leopard
e06189f83f feat(dev): add Laravel Debugbar for development environment
- Installed barryvdh/laravel-debugbar package (dev dependency)
- Enabled debugbar only in dev environment via DEBUGBAR_ENABLED=true
- Staging and production remain clean (following 12-Factor App principles)

Benefits:
- Monitor Redis cache performance (hits/misses)
- Inspect all database queries and execution times
- Identify N+1 query problems
- Debug request/response data in real-time
- Profile application performance

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 15:18:03 -07:00
Jon Leopard
014f37ae34 fix(seeder): fix component types and increase picking ticket number length
- Changed component types from invalid 'cannabis' to valid 'flower'/'concentrate'
- Added migration to increase picking_ticket_number from varchar(5) to varchar(20)
- Ensures DevSeeder uses correct "PT-" prefix format for picking ticket numbers
- Resolves check constraint violations preventing seeding

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 15:06:51 -07:00
Jon Leopard
c3aad9e966 fix(k8s): remove Redis password requirement in dev environment
Dev Redis now runs without password authentication:
- Simpler configuration (no secret management needed)
- Still secure (behind K8s ClusterIP, not exposed)
- Matches local Sail setup (no password by default)

Production/staging will use password via app-secrets.
2025-10-27 14:49:29 -07:00
Jon Leopard
25ec417835 feat(k8s): add Redis to all environments for Phase 1 cache implementation
Infrastructure Changes:
- Add Redis StatefulSet to k8s/base (1Gi storage, 256Mi RAM limit)
- Configure CACHE_STORE=redis across all environments
- Keep SESSION_DRIVER=database and QUEUE_CONNECTION=database (Phase 2/3)

Configuration Updates:
- Update ConfigMap with Redis connection settings (REDIS_HOST, REDIS_PORT)
- Configure Redis per environment (dev/staging/prod namespaces)
- Add Redis to base kustomization

URL Corrections:
- Fix dev.cannabrands.com (was dev-1.cannabrands.com)
- Fix staging.cannabrands.com (was staging-1.cannabrands.com)
- Fix hub.cannabrands.com (was hub-1.cannabrands.com)

Documentation:
- Add k8s/REDIS_SETUP.md with deployment guide
- Document phased Redis adoption strategy (cache → sessions → queues)
- Include troubleshooting guide and monitoring recommendations

Why:
- Improves page load times 2-5x (1-3ms cache vs 50-100ms database)
- Enables future horizontal scaling (multi-pod sessions)
- Follows 12-Factor App dev/prod parity principle
- Prepares infrastructure for background job processing

Phase 1 (Current): Cache only (safe, immediate benefits)
Phase 2 (2 weeks): Sessions (after validation)
Phase 3 (1-2 months): Queues + Laravel Horizon
2025-10-27 14:45:06 -07:00
Jon Leopard
1ee248b641 refactor: add ROLE_SUPER_ADMIN constant and fix SellerNotificationService
- Add User::ROLE_SUPER_ADMIN constant for type-safe role checks
- Fix SellerNotificationService using 'super_admin' instead of 'Super Admin'
- Document dual auth system (user_type for routes, Spatie for permissions)
- Use Title Case with Spaces per Spatie convention

This fixes a bug where seller notifications would not be sent to admins
because the role query was using 'super_admin' which doesn't exist in
the database (actual role name is 'Super Admin').
2025-10-27 11:50:59 -07:00
Jon
5656390ffc Merge pull request 'refactor: optimize Brand model and fix DevSeeder schema mismatches' (#10) from feature/enhance-dev-seeder into develop
Reviewed-on: https://code.cannabrands.com/Cannabrands/hub/pulls/10
2025-10-27 18:10:45 +00:00
Jon Leopard
bfc2fb806c fix(ci): use array cache/session in tests for speed and isolation
Environment variables now override .env.example during testing to use
in-memory drivers (array cache, array session, sync queue).

Why:
- Tests run 2-3x faster without Redis/external services
- Better test isolation (no cache state leakage between tests)
- Industry standard Laravel testing convention
- We're testing business logic, not cache behavior

Note: If we implement Redis-specific features (rate limiting, cache locks),
we can add Redis service to CI and test those features specifically.
2025-10-27 11:03:57 -07:00
Jon Leopard
f36b690d48 refactor: align config defaults with PostgreSQL/Redis stack
Changes:
- Update config/database.php default from 'sqlite' to 'pgsql'
- Update config/cache.php default from 'database' to 'redis'
- Update .env.example APP_NAME to 'Cannabrands Hub'
- Fix CI .env to use CACHE_STORE (not CACHE_DRIVER)

Why:
- Config defaults should reflect actual stack, not Laravel's generic defaults
- Prevents confusing SQLite errors when .env is missing
- Forces proper configuration (fail fast principle)
- Documents tech stack decisions in config files
- Fixes CI build error from incorrect cache variable name
2025-10-27 10:57:51 -07:00
Jon Leopard
db65e6394e chore(ci): update app name to 'Cannabrands Hub' for consistency 2025-10-27 10:50:01 -07:00
Jon Leopard
aa507ae949 fix(ci): create .env before composer install to prevent SQLite cache error
During composer install, Laravel's package:discover command boots the application
and AppServiceProvider tries to use cache(). Without a .env file, Laravel defaults
to SQLite cache driver, causing the build to fail.

Solution: Create minimal .env with CACHE_DRIVER=array before running composer install.
This prevents cache from requiring any database during package discovery.
2025-10-27 10:34:22 -07:00
Jon Leopard
67a8d28416 refactor: optimize Brand model and fix DevSeeder schema mismatches
## Changes

### Brand Model Optimization (app/Models/Brand.php)
- Removed 15+ bloat fields from old codebase that don't exist in DB schema:
  - story, product_categories, target_market, price_tier
  - minimum_order_quantity, lead_time_days, created_by
  - marketing_materials, certifications, geographic_restrictions, etc.
- Fixed field names to match optimized schema:
  - website_url, colors, instagram_handle, facebook_url, twitter_handle, is_public
- Added sku_prefix (exists via migration 2025_10_21_174544)
- Removed unused scopes and methods that referenced deleted fields
- Result: Lean model with 18 fields (down from 30+)

### DevSeeder Fixes (database/seeders/DevSeeder.php)
- Updated brand definitions to use only valid schema fields
- Removed: product_categories, target_market, price_tier, minimum_order_quantity,
  lead_time_days, created_by
- Now uses: business_id, name, sku_prefix, description, tagline, is_active,
  is_public, is_featured, sort_order

### Other
- Fixed SuperAdminSeeder role assignment (super_admin → Super Admin)
- Updated DATABASE_STRATEGY.md with comprehensive seeder documentation

## Why
The Brand model was pulling in unoptimized fields from the old codebase that
didn't exist in the current database schema. This caused "whack-a-mole" errors
during seeding. The solution was to align the model with the lean, optimized
schema rather than add migrations for bloat fields.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 10:22:12 -07:00
Jon Leopard
aa6f6272b6 chore: add Filament font updates and ignore Clockwork storage
- Add /storage/clockwork to .gitignore (runtime profiler data)
- Track new Filament Inter font variants generated during Filament update

These font files were generated when Filament was updated and are part
of the Filament asset build process.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-25 16:46:35 -07:00
Jon Leopard
b9343dc259 fix: reduce version cache TTL to 5 seconds for immediate dirty status
The previous 1-hour cache TTL meant that if a developer made changes or
committed code, the version display would show stale information for up
to 60 minutes. This defeats the purpose of the dirty detection.

Changed cache TTL from 1 hour to 5 seconds:
- Still prevents running git commands on every view render (the root cause
  of 3-5s page loads - would run 60-150 times per Filament page)
- Short enough that dirty status updates within one browser refresh
- When you save a file, next page load (5s later) shows "-dirty"
- When you commit, next page load (5s later) removes "-dirty"

Performance impact remains excellent:
- First request in 5-second window: runs git commands once (~50ms)
- Subsequent requests in same window: use cached data (< 1ms)
- Average Filament page with 30 views: 50ms instead of 1500ms

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-25 16:36:04 -07:00
Jon Leopard
ea970c192c perf: cache git version data to fix 3-5s page load times
This commit resolves a critical performance issue where Filament admin
pages were taking 3-5 seconds to load due to excessive shell_exec() calls.

## Root Cause
The View::composer('*') in AppServiceProvider was running 3 git shell
commands (git rev-parse, git diff, git diff --cached) on EVERY view render.
Since Filament renders 20-50+ views per page (widgets, tables, components,
partials), this resulted in 60-150 shell command executions per page load,
each taking ~20-50ms.

## Solution
- Wrapped git version detection in cache()->remember() with 1-hour TTL
- Git commands now run once per hour instead of per-view
- Version data cached and reused across all view renders

## Performance Impact
- Before: 3-5 second page loads
- After: 354ms page loads
- Improvement: 10-14x faster

## Additional Optimizations
- Fixed N+1 queries in UserResource and BusinessResource by using
  eager-loaded relationships instead of querying
- Added eager loading in ListUsers and ListBusinesses pages
- Removed debug middleware from AdminPanelProvider

## Trade-offs
Version info may be up to 1 hour stale if commits/checkouts happen between
cache refreshes. Can manually clear with: php artisan cache:clear

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-25 16:34:43 -07:00
Jon Leopard
5debd5d91e docs: enhance CLAUDE.md with comprehensive development guidelines
Added sections to improve AI assistant and developer guidance:
- References to all existing documentation files
- Strong "NEVER" directives for common mistakes
- CI/CD pipeline requirements and workflow
- Testing requirements and standards
- Clear architectural constraints (PostgreSQL, no raw SQL, etc.)

This ensures consistent development practices across all contributors
(both human developers and AI assistants using Claude Code).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-25 15:06:39 -07:00
Jon Leopard
fe344c9c4f k8s: update dev environment hostname to dev.cannabrands.com
Changed ingress hostname from dev-1.cannabrands.com to dev.cannabrands.com
to match the DNS record allocated by DevOps (185.149.70.106).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-24 17:33:45 -07:00
Jon Leopard
9e5b8ed691 docs: add Docker registry cleanup policy documentation
- Document Gitea cleanup rules configuration for Container packages
- Retention policy: keep production releases forever, last 50 SHA tags, 90-day limit
- Workaround for Gitea UI limitation: one criterion per rule
- Storage impact calculations show 85% savings (~23GB vs ~100GB/year)
- Includes verification procedures and troubleshooting guidance

Related: Addresses image retention policy discussion for code.cannabrands.com registry

Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-24 13:11:30 -07:00
Jon Leopard
8de17248f0 fix: allow pull requests from feature branches to trigger CI
Previously, PRs from feature branches were blocked because the
'when' condition required the source branch to be develop or master.

This change separates the conditions:
- Push events: Only develop/master branches
- Pull request events: Any branch targeting develop/master
- Tag events: All tags

This allows developers to create PRs from feature branches and have
CI run automatically for code review.

Fixes issue where PR #8 from feature/email-templates didn't trigger CI.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-24 10:45:27 -07:00
Jon Leopard
3656e547b8 fix: increase health probe timeouts for Laravel first-boot
Laravel's first requests take 11-13 seconds due to view compilation
and route caching. Updated probe configuration to prevent premature
container restarts:

- Liveness probe: 60s initial delay, 15s timeout, 3 failures allowed
- Readiness probe: 15s initial delay, 10s timeout, 3 failures allowed

This prevents Kubernetes from killing the container before Laravel
completes its warm-up phase.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-24 10:02:58 -07:00
Jon Leopard
ba4554d727 fix: add missing LogRequestTiming middleware and set imagePullPolicy
Changes:
- Add app/Http/Middleware/LogRequestTiming.php (was untracked)
- Set imagePullPolicy: Always in k8s/base/app-deployment.yaml

The LogRequestTiming middleware was referenced in AdminPanelProvider
but the file wasn't committed, causing CI/CD build failures.

Setting imagePullPolicy: Always ensures Kubernetes always pulls
the latest image from registry when using mutable tags like :dev.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-24 09:46:18 -07:00
Jon Leopard
54a08bef69 fix: configure PHP-FPM to use TCP and fix pool configuration
This commit resolves critical PHP-FPM configuration issues:

1. PHP-FPM Pool Configuration:
   - Remove default www.conf to prevent conflicts
   - Add user/group directives (www-data) to custom pool config
   - Use TCP listen (127.0.0.1:9000) instead of Unix socket for reliability
   - Simpler than Unix sockets, works consistently across environments

2. Nginx FastCGI Configuration:
   - Update nginx to use TCP (127.0.0.1:9000) instead of Unix socket
   - Matches PHP-FPM listen configuration

3. Kubernetes Deployment:
   - Update container port from 8000 to 80 (matches Dockerfile EXPOSE 80)
   - Fix health probe ports to use port 80
   - Fix Service targetPort to use port 80

Root causes fixed:
- Default www.conf conflicted with custom configuration
- Missing user/group directives caused PHP-FPM startup failures
- Unix socket permission issues in /var/run directory
- Port mismatches prevented health checks from succeeding

Tested locally with docker-compose prod-test environment - application
now responds correctly with HTTP 200.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-23 23:53:23 -07:00
Jon Leopard
d5b26bb1b2 fix: PHP-FPM socket configuration and port mismatches
This commit resolves two critical deployment issues:

1. PHP-FPM Socket Configuration:
   - Remove default www.conf that was listening on TCP port 9000
   - Replace with custom config using Unix socket at /var/run/php-fpm.sock
   - Fixes "No such file or directory" errors in nginx error logs
   - Unix socket provides better performance than TCP

2. Port Configuration Alignment:
   - Update k8s deployment from port 8000 to port 80 (matches EXPOSE 80 in Dockerfile)
   - Fix containerPort, health probe ports, and Service targetPort
   - Resolves connection refused and 502 Bad Gateway errors

Root cause: Default PHP-FPM pool config was loaded before our custom config,
preventing the Unix socket from being created. The first [www] pool definition
wins in PHP-FPM configuration.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-23 22:46:35 -07:00
Jon Leopard
36eadb3bb4 feat: add local production Docker testing workflow
Add tooling to test production Docker images locally before CI/CD:

- docker-compose.prod-test.yml: Local production test environment
- TESTING_PRODUCTION_LOCALLY.md: Comprehensive testing guide
- Makefile: Added prod-test-* commands for easy testing

New Make commands:
- make prod-test        - Build and run production image
- make prod-test-build  - Build with no cache
- make prod-test-up     - Start in background
- make prod-test-logs   - View logs
- make prod-test-shell  - Debug in container
- make prod-test-status - Check supervisor status
- make prod-test-clean  - Fresh start

Benefits:
- 5-10min faster feedback vs CI/CD builds
- Easier debugging with direct container access
- Catch Docker/config issues before pushing
- Test supervisor, nginx, php-fpm, queue workers locally

Usage: make prod-test

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-23 22:31:45 -07:00
Jon Leopard
0db548d1fe fix: update supervisor to use database queue driver instead of redis
Change Laravel queue worker from redis to database driver to match
the QUEUE_CONNECTION=database configuration in configmap.

This fixes the supervisor/queue worker crash on startup.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-23 22:24:56 -07:00
Jon Leopard
1cb7d3b093 fix: add Longhorn storage and fix Docker image directories
Kubernetes changes:
- Add storageClassName: longhorn to PostgreSQL StatefulSet
- Fix init container to create storage directories and clear config cache
- Add VIEW_COMPILED_PATH environment variable to configmap

Dockerfile changes:
- Create all required Laravel storage subdirectories (sessions, views, cache, logs)
- Create /var/log/supervisor directory for supervisord
- Remove build-time Laravel caching (config/route/view cache)
  - Build-time caching was causing runtime errors with actual environment config
  - Cache will be generated at runtime with real environment variables

This fixes:
- PostgreSQL PVC pending issue (no storage class)
- Migration init container cache path errors
- Supervisor log directory missing errors

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-23 22:12:37 -07:00
922 changed files with 55125 additions and 151608 deletions

9
.blade-formatter.json Normal file
View File

@@ -0,0 +1,9 @@
{
"indentSize": 4,
"wrapAttributes": "auto",
"wrapLineLength": 120,
"endWithNewLine": true,
"useTabs": false,
"sortTailwindcssClasses": true,
"sortHtmlAttributes": "none"
}

View File

@@ -0,0 +1,35 @@
# Number Input Spinners Removed
## Summary
All number input spinner arrows (up/down buttons) have been globally removed from the application.
## Implementation
CSS has been added to both main layout files to hide spinners:
1. **app.blade.php** (lines 17-31)
2. **app-with-sidebar.blade.php** (lines 17-31)
## CSS Used
```css
/* Chrome, Safari, Edge, Opera */
input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
input[type="number"] {
-moz-appearance: textfield;
appearance: textfield;
}
```
## User Preference
User specifically requested:
- Remove up/down arrows on number input boxes
- Apply this globally across all pages
- Remember this preference for future pages
## Date
2025-11-05

View File

@@ -0,0 +1,27 @@
{
"permissions": {
"allow": [
"Bash(test:*)",
"Bash(docker exec:*)",
"Bash(docker stats:*)",
"Bash(docker logs:*)",
"Bash(docker-compose down:*)",
"Bash(docker-compose up:*)",
"Bash(php --version:*)",
"Bash(docker-compose build:*)",
"Bash(docker-compose restart:*)",
"Bash(find:*)",
"Bash(docker ps:*)",
"Bash(php -l:*)",
"Bash(curl:*)",
"Bash(cat:*)",
"Bash(docker update:*)",
"Bash(grep:*)",
"Bash(sed:*)",
"Bash(php artisan:*)",
"Bash(php check_blade.php:*)"
],
"deny": [],
"ask": []
}
}

View File

@@ -1,4 +1,4 @@
APP_NAME=Laravel
APP_NAME="Cannabrands Hub"
APP_ENV=local
APP_KEY=
APP_DEBUG=true
@@ -33,10 +33,30 @@ SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
BROADCAST_CONNECTION=reverb
FILESYSTEM_DISK=local
QUEUE_CONNECTION=redis
# Laravel Reverb (WebSocket Server for Real-Time Broadcasting)
# Generate credentials: php artisan reverb:install
REVERB_APP_ID=182567
REVERB_APP_KEY=ieplst7x2k8avnqcmmo6
REVERB_APP_SECRET=ckhvaobktbozwpljzlrv
# IMPORTANT: Use Docker service name when running in Sail
# - Docker/Sail: REVERB_HOST=reverb
# - Local dev: REVERB_HOST=localhost
REVERB_HOST=reverb
REVERB_PORT=8080
REVERB_SCHEME=http
# Vite Environment Variables (Frontend WebSocket Connection)
# IMPORTANT: Vite doesn't support ${} expansion - values must be LITERAL
# These values are for the browser, so always use "localhost" (not "reverb")
VITE_REVERB_APP_KEY=ieplst7x2k8avnqcmmo6
VITE_REVERB_HOST=localhost
VITE_REVERB_PORT=8080
VITE_REVERB_SCHEME=http
CACHE_STORE=redis
CACHE_PREFIX=
@@ -57,10 +77,25 @@ 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
VITE_APP_NAME="${APP_NAME}"

View File

@@ -1,6 +1,6 @@
#!/bin/sh
#
# Pre-push hook - Runs tests before pushing
# Pre-push hook - Runs tests before pushing (supports both Sail and K8s)
# Can be skipped with: git push --no-verify
#
@@ -8,11 +8,48 @@ echo "🧪 Running tests before push..."
echo " (Use 'git push --no-verify' to skip)"
echo ""
# Run tests
./vendor/bin/sail artisan test --parallel
# Detect which environment is running
SAIL_RUNNING=false
K8S_RUNNING=false
# Check exit code
if [ $? -ne 0 ]; then
# Check if Sail is running
if docker ps --format '{{.Names}}' | grep -q "sail" 2>/dev/null; then
SAIL_RUNNING=true
echo "📦 Detected Sail environment"
fi
# Check if k8s namespace exists for this worktree
BRANCH=$(git rev-parse --abbrev-ref HEAD)
K8S_NS=$(echo "$BRANCH" | sed 's/feature\//feat-/' | sed 's/bugfix\//fix-/' | sed 's/\//-/g')
if kubectl get namespace "$K8S_NS" >/dev/null 2>&1; then
K8S_RUNNING=true
echo "☸️ Detected K8s environment (namespace: $K8S_NS)"
fi
# Run tests in appropriate environment
if [ "$SAIL_RUNNING" = true ]; then
./vendor/bin/sail artisan test --parallel
TEST_EXIT_CODE=$?
elif [ "$K8S_RUNNING" = true ]; then
echo " Running tests in k8s pod..."
kubectl -n "$K8S_NS" exec deploy/web -- php artisan test
TEST_EXIT_CODE=$?
else
echo "⚠️ No environment running (Sail or K8s)"
echo " Skipping tests - please run tests manually"
echo ""
read -p "Continue push anyway? (y/n) " -n 1 -r
echo ""
if [ ! "$REPLY" = "y" ] && [ ! "$REPLY" = "Y" ]; then
echo "Push aborted"
exit 1
fi
exit 0
fi
# Check test results
if [ $TEST_EXIT_CODE -ne 0 ]; then
echo ""
echo "❌ Tests failed!"
echo ""

23
.gitignore vendored
View File

@@ -5,8 +5,10 @@
/public/storage
/storage/*.key
/storage/pail
/storage/clockwork
/vendor
.DS_Store
docker-compose.override.yml
.env
.env.backup
.env.production
@@ -36,3 +38,24 @@ yarn-error.log
# Version files (generated at build time or locally)
version.txt
version.env
# Local secrets backup (DO NOT COMMIT)
*SECRETS_BACKUP*
.cannabrands-secrets/
reverb-keys*
# Core dumps and debug files
core
core.*
*.core
# Random image files (screenshots, etc.)
*.png
*.jpg
*.jpeg
!public/**/*.png
!public/**/*.jpg
!public/**/*.jpeg
!resources/**/*.png
!resources/**/*.jpg
!resources/**/*.jpeg
.claude/settings.local.json

20
.stylelintrc.json Normal file
View File

@@ -0,0 +1,20 @@
{
"extends": "stylelint-config-standard",
"plugins": [
"stylelint-no-unsupported-browser-features"
],
"rules": {
"no-descending-specificity": null,
"selector-class-pattern": null,
"custom-property-pattern": null,
"declaration-block-no-duplicate-properties": true,
"no-duplicate-selectors": true
},
"ignoreFiles": [
"**/*.js",
"**/*.php",
"node_modules/**",
"vendor/**",
"public/**"
]
}

View File

@@ -1,27 +1,18 @@
# Woodpecker CI/CD Pipeline for Cannabrands CRM
# Woodpecker CI/CD Pipeline for Cannabrands Hub
# Documentation: https://woodpecker-ci.org/docs/intro
#
# 3-Environment Workflow:
# - develop branch → dev.cannabrands.com (unstable, daily integration)
# - master branch → staging.cannabrands.com (stable, pre-production)
# - tags (2025.X) → hub.cannabrands.com (production releases)
# - develop branch → dev.cannabrands.app (unstable, daily integration)
# - master branch → staging.cannabrands.app (stable, pre-production)
# - tags (2025.X) → cannabrands.app (production releases)
when:
- branch: [develop, master]
event: [push, pull_request]
- event: tag
event: push
- event: [pull_request, tag]
# PHP Syntax Check
# Install dependencies first (needed for php-lint to resolve traits/classes)
steps:
php-lint:
image: php:8.3-cli
commands:
- echo "Checking PHP syntax..."
- find app -name "*.php" -exec php -l {} \;
- find routes -name "*.php" -exec php -l {} \;
- find database -name "*.php" -exec php -l {} \;
- echo "PHP syntax check complete!"
# Restore Composer cache
restore-composer-cache:
image: meltwater/drone-cache:dev
@@ -47,6 +38,23 @@ steps:
- docker-php-ext-install -j$(nproc) intl pdo pdo_pgsql zip gd
- echo "Installing Composer..."
- curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer --quiet
- echo "Creating minimal .env for package discovery..."
- |
cat > .env << 'EOF'
APP_NAME="Cannabrands Hub"
APP_ENV=testing
APP_KEY=base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
APP_DEBUG=true
CACHE_STORE=array
SESSION_DRIVER=array
QUEUE_CONNECTION=sync
DB_CONNECTION=pgsql
DB_HOST=postgres
DB_PORT=5432
DB_DATABASE=testing
DB_USERNAME=testing
DB_PASSWORD=testing
EOF
- echo "Checking for cached dependencies..."
- |
if [ -d "vendor" ] && [ -f "vendor/autoload.php" ]; then
@@ -72,6 +80,16 @@ steps:
volumes:
- /tmp/woodpecker-cache:/tmp/cache
# PHP Syntax Check (runs after composer install so traits/classes are available)
php-lint:
image: php:8.3-cli
commands:
- echo "Checking PHP syntax..."
- find app -name "*.php" -exec php -l {} \;
- find routes -name "*.php" -exec php -l {} \;
- find database -name "*.php" -exec php -l {} \;
- echo "PHP syntax check complete!"
# Run Laravel Pint (Code Style)
code-style:
image: php:8.3-cli
@@ -81,20 +99,36 @@ steps:
- echo "Code style check complete!"
# Run PHPUnit Tests
# Note: Uses array cache/session for speed and isolation (Laravel convention)
# Redis + Reverb services used for real-time broadcasting tests
tests:
image: kirschbaumdevelopment/laravel-test-runner:8.3
environment:
APP_ENV: testing
BROADCAST_CONNECTION: reverb
CACHE_STORE: array
SESSION_DRIVER: array
QUEUE_CONNECTION: sync
DB_CONNECTION: pgsql
DB_HOST: postgres
DB_PORT: 5432
DB_DATABASE: testing
DB_USERNAME: testing
DB_PASSWORD: testing
REDIS_HOST: redis
REVERB_APP_ID: test-app-id
REVERB_APP_KEY: test-key
REVERB_APP_SECRET: test-secret
REVERB_HOST: localhost
REVERB_PORT: 8080
REVERB_SCHEME: http
commands:
- echo "Setting up Laravel environment..."
- cp .env.example .env
- php artisan key:generate
- echo "Starting Reverb server in background..."
- php artisan reverb:start --host=0.0.0.0 --port=8080 > /dev/null 2>&1 &
- sleep 2
- echo "Running tests..."
- php artisan test --parallel
- echo "Tests complete!"
@@ -103,42 +137,88 @@ steps:
build-image-dev:
image: woodpeckerci/plugin-docker-buildx
settings:
registry: code.cannabrands.com
repo: code.cannabrands.com/cannabrands/hub
registry: code.cannabrands.app
repo: code.cannabrands.app/cannabrands/hub
username:
from_secret: gitea_username
password:
from_secret: gitea_token
tags:
- dev # Latest dev build → dev.cannabrands.com
- dev # Latest dev build → dev.cannabrands.app
- dev-${CI_COMMIT_SHA:0:7} # Unique dev tag with SHA
- sha-${CI_COMMIT_SHA:0:7} # Commit SHA (industry standard)
- ${CI_COMMIT_BRANCH} # Branch name (develop)
build_args:
- GIT_COMMIT_SHA=${CI_COMMIT_SHA:0:7}
- APP_VERSION=dev
GIT_COMMIT_SHA: "${CI_COMMIT_SHA:0:7}"
APP_VERSION: "dev"
VITE_REVERB_APP_KEY: "6VDQTxU0fknXHCgKOI906Py03abktP8GatzNw3DvJkU="
VITE_REVERB_HOST: "dev.cannabrands.app"
VITE_REVERB_PORT: "443"
VITE_REVERB_SCHEME: "https"
cache_images:
- code.cannabrands.app/cannabrands/hub:buildcache-dev
platforms: linux/amd64
when:
branch: develop
event: push
status: success
# Auto-deploy to dev.cannabrands.app (develop branch only)
deploy-dev:
image: bitnami/kubectl:latest
environment:
KUBECONFIG_CONTENT:
from_secret: kubeconfig_dev
commands:
- echo "🚀 Auto-deploying to dev.cannabrands.app..."
- echo "Commit SHA${CI_COMMIT_SHA:0:7}"
- echo ""
# Setup kubeconfig
- mkdir -p ~/.kube
- echo "$KUBECONFIG_CONTENT" | tr -d '[:space:]' | base64 -d > ~/.kube/config
- chmod 600 ~/.kube/config
# Update deployment to use new SHA-tagged image (both app and init containers)
- |
kubectl set image deployment/cannabrands-hub \
app=code.cannabrands.app/cannabrands/hub:dev-${CI_COMMIT_SHA:0:7} \
migrate=code.cannabrands.app/cannabrands/hub:dev-${CI_COMMIT_SHA:0:7} \
-n cannabrands-dev
# Wait for rollout to complete (timeout 5 minutes)
- kubectl rollout status deployment/cannabrands-hub -n cannabrands-dev --timeout=300s
# Verify deployment health
- |
echo ""
echo "✅ Deployment successful!"
echo "Pod status:"
kubectl get pods -n cannabrands-dev -l app=cannabrands-hub
echo ""
echo "Image deployed:"
kubectl get deployment cannabrands-hub -n cannabrands-dev -o jsonpath='{.spec.template.spec.containers[0].image}'
echo ""
when:
branch: develop
event: push
status: success
# Build and push Docker image for STAGING environment (master branch)
build-image-staging:
image: woodpeckerci/plugin-docker-buildx
settings:
registry: code.cannabrands.com
repo: code.cannabrands.com/cannabrands/hub
registry: code.cannabrands.app
repo: code.cannabrands.app/cannabrands/hub
username:
from_secret: gitea_username
password:
from_secret: gitea_token
tags:
- staging # Latest staging build → staging.cannabrands.com
- staging # Latest staging build → staging.cannabrands.app
- sha-${CI_COMMIT_SHA:0:7} # Commit SHA (industry standard)
- ${CI_COMMIT_BRANCH} # Branch name (master)
build_args:
- GIT_COMMIT_SHA=${CI_COMMIT_SHA:0:7}
- APP_VERSION=staging
GIT_COMMIT_SHA: "${CI_COMMIT_SHA:0:7}"
APP_VERSION: "staging"
cache_images:
- code.cannabrands.app/cannabrands/hub:buildcache-staging
platforms: linux/amd64
when:
branch: master
@@ -149,8 +229,8 @@ steps:
build-image-release:
image: woodpeckerci/plugin-docker-buildx
settings:
registry: code.cannabrands.com
repo: code.cannabrands.com/cannabrands/hub
registry: code.cannabrands.app
repo: code.cannabrands.app/cannabrands/hub
username:
from_secret: gitea_username
password:
@@ -159,8 +239,10 @@ steps:
- ${CI_COMMIT_TAG} # CalVer tag (e.g., 2025.10.1)
- latest # Latest stable release
build_args:
- GIT_COMMIT_SHA=${CI_COMMIT_SHA:0:7}
- APP_VERSION=${CI_COMMIT_TAG}
GIT_COMMIT_SHA: "${CI_COMMIT_SHA:0:7}"
APP_VERSION: "${CI_COMMIT_TAG}"
cache_images:
- code.cannabrands.app/cannabrands/hub:buildcache-prod
platforms: linux/amd64
when:
event: tag
@@ -181,14 +263,14 @@ steps:
echo "🎉 PRODUCTION RELEASE BUILD COMPLETE"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Version: ${CI_COMMIT_TAG}"
echo "Registry: code.cannabrands.com/cannabrands/hub"
echo "Registry: code.cannabrands.app/cannabrands/hub"
echo ""
echo "Available as:"
echo " - code.cannabrands.com/cannabrands/hub:${CI_COMMIT_TAG}"
echo " - code.cannabrands.com/cannabrands/hub:latest"
echo " - code.cannabrands.app/cannabrands/hub:${CI_COMMIT_TAG}"
echo " - code.cannabrands.app/cannabrands/hub:latest"
echo ""
echo "🚀 Deploy to PRODUCTION (hub.cannabrands.com):"
echo " docker pull code.cannabrands.com/cannabrands/hub:${CI_COMMIT_TAG}"
echo "🚀 Deploy to PRODUCTION (cannabrands.app):"
echo " docker pull code.cannabrands.app/cannabrands/hub:${CI_COMMIT_TAG}"
echo " docker-compose -f docker-compose.production.yml up -d"
echo ""
echo "⚠️ This is a CUSTOMER-FACING release!"
@@ -199,18 +281,18 @@ steps:
echo "🧪 STAGING BUILD COMPLETE"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Branch: master"
echo "Registry: code.cannabrands.com/cannabrands/hub"
echo "Registry: code.cannabrands.app/cannabrands/hub"
echo "Tags:"
echo " - staging"
echo " - sha-${CI_COMMIT_SHA:0:7}"
echo " - ${CI_COMMIT_BRANCH}"
echo ""
echo "📦 Deploy to STAGING (staging.cannabrands.com):"
echo " docker pull code.cannabrands.com/cannabrands/hub:staging"
echo "📦 Deploy to STAGING (staging.cannabrands.app):"
echo " docker pull code.cannabrands.app/cannabrands/hub:staging"
echo " docker-compose -f docker-compose.staging.yml up -d"
echo ""
echo "👥 Next steps:"
echo " 1. Super-admin tests on staging.cannabrands.com"
echo " 1. Super-admin tests on staging.cannabrands.app"
echo " 2. Validate all features work"
echo " 3. When ready, create production tag:"
echo " git tag -a 2025.10.1 -m 'Release 2025.10.1'"
@@ -219,30 +301,34 @@ steps:
elif [ "${CI_PIPELINE_EVENT}" = "push" ] && [ "${CI_COMMIT_BRANCH}" = "develop" ]; then
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "🔧 DEV BUILD COMPLETE"
echo "🚀 DEV BUILD + AUTO-DEPLOY COMPLETE"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Branch: develop"
echo "Registry: code.cannabrands.com/cannabrands/hub"
echo "Tags:"
echo " - dev"
echo " - sha-${CI_COMMIT_SHA:0:7}"
echo " - ${CI_COMMIT_BRANCH}"
echo "Commit: ${CI_COMMIT_SHA:0:7}"
echo ""
echo "📦 Deploy to DEV (dev.cannabrands.com):"
echo " docker pull code.cannabrands.com/cannabrands/hub:dev"
echo " docker-compose -f docker-compose.dev.yml up -d"
echo "✅ Built & Tagged:"
echo " - code.cannabrands.app/cannabrands/hub:dev"
echo " - code.cannabrands.app/cannabrands/hub:dev-${CI_COMMIT_SHA:0:7}"
echo " - code.cannabrands.app/cannabrands/hub:sha-${CI_COMMIT_SHA:0:7}"
echo ""
echo "✅ Auto-Deployed to Kubernetes:"
echo " - Environment: dev.cannabrands.app"
echo " - Namespace: cannabrands-dev"
echo " - Image: dev-${CI_COMMIT_SHA:0:7}"
echo ""
echo "🧪 Test your changes:"
echo " - Visit: https://dev.cannabrands.app"
echo " - Login: admin@example.com / password"
echo " - Check: https://dev.cannabrands.app/telescope"
echo ""
echo "👥 Next steps:"
echo " 1. Test integration on dev.cannabrands.com"
echo " 2. When stable, create PR to master:"
echo " git checkout master"
echo " git pull origin master"
echo " git merge develop"
echo " git push origin master"
echo " 1. Verify feature works on dev.cannabrands.app"
echo " 2. When stable, merge to master for staging:"
echo " git checkout master && git merge develop && git push"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
fi
# Database service for tests
# Services for tests
services:
postgres:
image: postgres:15
@@ -250,3 +336,8 @@ services:
POSTGRES_USER: testing
POSTGRES_PASSWORD: testing
POSTGRES_DB: testing
redis:
image: redis:7-alpine
commands:
- redis-server --bind 0.0.0.0

View File

@@ -0,0 +1,295 @@
# Auto-Deploy to Dev Environment - Setup Guide
## Overview
This document explains how to set up automatic deployment to `dev.cannabrands.app` after successful CI builds.
**What happens:**
```
Push to develop → CI Passes → Docker Build → Auto-Deploy to K8s → Live on dev.cannabrands.app
```
---
## Why Auto-Deploy Dev?
**Fast Feedback**: Changes live in ~5.5 minutes (no manual kubectl needed)
**Industry Standard**: GitHub, GitLab, and most teams auto-deploy dev
**Safe**: Only after all tests pass + CI verification
**Aligned with Workflow**: Follows your "Graduated Enforcement" philosophy
**Staging/Production remain manual** for maximum safety.
---
## One-Time Setup (5 Minutes)
### Step 1: Get Your Kubeconfig
```bash
# Base64 encode your kubeconfig for Woodpecker
cat ~/.kube/config | base64 | pbcopy # macOS (copies to clipboard)
# OR
cat ~/.kube/config | base64 # Linux/Windows (copy output)
```
### Step 2: Add Secret to Woodpecker
1. Go to Woodpecker CI: `https://ci.cannabrands.app`
2. Navigate to: **Repositories → cannabrands/hub → Settings → Secrets**
3. Click **"Add Secret"**
4. Fill in:
- **Name**: `kubeconfig_dev`
- **Value**: *[paste base64 output from Step 1]*
- **Events**: Check "push" and "tag"
5. Click **"Save"**
### Step 3: Test It!
```bash
# Make any change to develop branch
git checkout develop
echo "# Test auto-deploy" >> README.md
git add README.md
git commit -m "test: verify auto-deploy works"
git push origin develop
# Watch Woodpecker CI:
# - Tests pass ✅
# - Build completes ✅
# - Deploy step runs ✅
# - Check dev.cannabrands.app - your change is live!
```
---
## How It Works
### Image Tagging Strategy
**Before (Mutable Tags - Problematic):**
```
code.cannabrands.app/cannabrands/hub:dev # Overwritten each build
```
**After (Immutable Tags - Best Practice):**
```
code.cannabrands.app/cannabrands/hub:dev-a28d5b5 # Unique SHA tag
code.cannabrands.app/cannabrands/hub:dev # Latest dev (convenience)
code.cannabrands.app/cannabrands/hub:sha-a28d5b5 # Generic SHA
```
### Auto-Deploy Flow
```mermaid
graph LR
A[Push to develop] --> B[Pre-push: Tests]
B --> C[CI: Lint + Tests]
C --> D[CI: Build Docker Image]
D --> E[Tag: dev-SHA]
E --> F[Deploy Step: kubectl set image]
F --> G[Rollout & Health Check]
G --> H[Live on dev.cannabrands.app]
```
**Key Safety Features:**
- Only runs if ALL CI checks pass
- Uses unique SHA tags (no cache issues)
- Waits for rollout completion
- Verifies pod health before finishing
---
## Rollback (If Needed)
If a deployment breaks dev, roll back to the previous version:
```bash
# 1. Find previous working image tag
kubectl get deployment cannabrands-hub -n cannabrands-dev \
-o jsonpath='{.spec.template.spec.containers[0].image}'
# Output: code.cannabrands.app/cannabrands/hub:dev-a28d5b5
# 2. Check git log for previous commit
git log --oneline develop | head -5
# 3. Rollback to previous SHA
kubectl set image deployment/cannabrands-hub \
app=code.cannabrands.app/cannabrands/hub:dev-PREVIOUS_SHA \
-n cannabrands-dev
# 4. Verify rollback
kubectl rollout status deployment/cannabrands-hub -n cannabrands-dev
```
---
## CI Pipeline Stages
| Stage | Duration | On Failure | Auto-Deploy? |
|-------|----------|------------|--------------|
| PHP Lint | ~10s | ❌ Stop | No |
| Code Style (Pint) | ~15s | ❌ Stop | No |
| PHPUnit Tests | ~30s | ❌ Stop | No |
| Docker Build | ~4min | ❌ Stop | No |
| **Deploy to Dev** | ~30s | ⚠️ Alerts | **Yes** (if all passed) |
**Total Time to Live**: ~5.5 minutes from push to deployed
---
## Extending to Staging (Optional)
Want auto-deploy for staging too? Add this to `.woodpecker/.ci.yml`:
```yaml
# Auto-deploy to staging.cannabrands.app (master branch)
deploy-staging:
image: bitnami/kubectl:latest
environment:
KUBECONFIG_CONTENT:
from_secret: kubeconfig_staging
commands:
- mkdir -p ~/.kube
- echo "$KUBECONFIG_CONTENT" | base64 -d > ~/.kube/config
- chmod 600 ~/.kube/config
- |
kubectl set image deployment/cannabrands-hub \
app=code.cannabrands.app/cannabrands/hub:staging-${CI_COMMIT_SHA:0:7} \
-n cannabrands-staging
- kubectl rollout status deployment/cannabrands-hub -n cannabrands-staging --timeout=300s
when:
branch: master
event: push
status: success
```
Then add `kubeconfig_staging` secret in Woodpecker.
**Recommendation**: Keep staging manual until team is comfortable with auto-deploy.
---
## Monitoring
### Check Deployment Status
```bash
# View current image
kubectl get deployment cannabrands-hub -n cannabrands-dev \
-o jsonpath='{.spec.template.spec.containers[0].image}'
# View recent deployments
kubectl rollout history deployment/cannabrands-hub -n cannabrands-dev
# View pod logs
kubectl logs -n cannabrands-dev deployment/cannabrands-hub --tail=100
```
### Woodpecker CI Logs
1. Go to: `https://ci.cannabrands.app`
2. Click on latest pipeline
3. Expand "deploy-dev" step
4. See deployment output:
```
🚀 Auto-deploying to dev.cannabrands.app...
Commit SHA: a28d5b5
deployment.apps/cannabrands-hub image updated
Waiting for deployment "cannabrands-hub" rollout to finish...
deployment "cannabrands-hub" successfully rolled out
✅ Deployment successful!
Pod status:
NAME READY STATUS RESTARTS AGE
cannabrands-hub-7d85986845-gnkbv 1/1 Running 0 45s
Image deployed:
code.cannabrands.app/cannabrands/hub:dev-a28d5b5
```
---
## Troubleshooting
### ❌ Deploy Step Fails: "unauthorized"
**Problem**: Kubeconfig secret not set or expired
**Fix**:
```bash
# Re-encode and update secret in Woodpecker
cat ~/.kube/config | base64
# Update kubeconfig_dev secret with new value
```
### ❌ Deploy Step Fails: "deployment not found"
**Problem**: Wrong namespace or deployment name
**Fix**: Verify deployment exists:
```bash
kubectl get deployment -n cannabrands-dev
```
### ❌ Rollout Timeout (5 minutes)
**Problem**: Pod crashing or image pull failing
**Fix**:
```bash
# Check pod status
kubectl get pods -n cannabrands-dev -l app=cannabrands-hub
# Check pod logs
kubectl logs -n cannabrands-dev -l app=cannabrands-hub --tail=50
# Check events
kubectl get events -n cannabrands-dev --sort-by='.lastTimestamp'
```
### ✅ CI Passes But Old Image Still Running
**Problem**: This shouldn't happen with SHA tags, but if it does:
**Fix**:
```bash
# Force recreation of pods
kubectl rollout restart deployment/cannabrands-hub -n cannabrands-dev
```
---
## Benefits Recap
**Before Auto-Deploy:**
```
Push → Wait for CI → Manually kubectl → Hope you remembered → Test
Time: ~7 minutes + manual step
```
**After Auto-Deploy:**
```
Push → Wait for CI → Deployed automatically → Test
Time: ~5.5 minutes, zero manual steps
```
**Additional Benefits:**
- ✅ Unique tags prevent cache issues
- ✅ Easy rollbacks (change to previous SHA)
- ✅ Audit trail (every deployment logged in CI)
- ✅ Follows industry best practices
- ✅ Aligns with your graduated enforcement philosophy
---
## Summary
1. **One-time setup**: Add `kubeconfig_dev` secret to Woodpecker (5 minutes)
2. **Test it**: Push to develop, watch it auto-deploy
3. **Enjoy**: Fast feedback, no manual kubectl needed
4. **Rollback**: Easy if needed (kubectl set image to previous SHA)
**Questions?** Check Woodpecker CI logs or ask in #engineering Slack.

View File

@@ -47,8 +47,8 @@ steps:
build-image:
image: plugins/docker
settings:
registry: code.cannabrands.com
repo: code.cannabrands.com/cannabrands/hub
registry: code.cannabrands.app
repo: code.cannabrands.app/cannabrands/hub
tags: [latest, ${CI_COMMIT_SHA:0:8}]
when:
branch: master
@@ -68,7 +68,7 @@ steps:
```bash
# On production server
ssh cannabrands-prod
docker pull code.cannabrands.com/cannabrands/hub:bef77df8
docker pull code.cannabrands.app/cannabrands/hub:bef77df8
docker-compose up -d
# Or use deployment tool like Ansible, Deployer, etc.
```
@@ -108,7 +108,7 @@ steps:
from_secret: ssh_private_key
script:
- cd /var/www/cannabrands
- docker pull code.cannabrands.com/cannabrands/hub:${CI_COMMIT_SHA:0:8}
- docker pull code.cannabrands.app/cannabrands/hub:${CI_COMMIT_SHA:0:8}
- docker-compose up -d
- docker exec cannabrands php artisan migrate --force
- docker exec cannabrands php artisan config:cache
@@ -141,7 +141,7 @@ Developer → Feature branch → CI tests
**Git branching:**
```
develop (latest features) → auto-deploy to staging.cannabrands.com
develop (latest features) → auto-deploy to staging.cannabrands.app
master (production-ready) → auto-deploy to app.cannabrands.com
```
@@ -154,13 +154,13 @@ steps:
deploy-staging:
image: appleboy/drone-ssh
settings:
host: staging.cannabrands.com
host: staging.cannabrands.app
username: deploy
key:
from_secret: ssh_private_key
script:
- cd /var/www/cannabrands
- docker pull code.cannabrands.com/cannabrands/hub:${CI_COMMIT_SHA:0:8}
- docker pull code.cannabrands.app/cannabrands/hub:${CI_COMMIT_SHA:0:8}
- docker-compose up -d
when:
branch: develop
@@ -176,7 +176,7 @@ steps:
from_secret: ssh_private_key
script:
- cd /var/www/cannabrands
- docker pull code.cannabrands.com/cannabrands/hub:${CI_COMMIT_SHA:0:8}
- docker pull code.cannabrands.app/cannabrands/hub:${CI_COMMIT_SHA:0:8}
- docker-compose up -d
when:
branch: master
@@ -337,7 +337,7 @@ Local Development:
Staging:
- Mirrors production
- staging.cannabrands.com
- staging.cannabrands.app
- Real-like data (anonymized)
- Auto-deployed from develop branch
@@ -367,7 +367,7 @@ Production:
```bash
# Quick rollback (under 2 minutes)
ssh cannabrands-prod
docker pull code.cannabrands.com/cannabrands/hub:PREVIOUS_COMMIT_SHA
docker pull code.cannabrands.app/cannabrands/hub:PREVIOUS_COMMIT_SHA
docker-compose up -d
# Database rollback (if migrations ran)
@@ -536,8 +536,8 @@ steps:
build-image:
image: plugins/docker
settings:
registry: code.cannabrands.com
repo: code.cannabrands.com/cannabrands/hub
registry: code.cannabrands.app
repo: code.cannabrands.app/cannabrands/hub
tags:
- ${CI_COMMIT_BRANCH}
- ${CI_COMMIT_SHA:0:8}
@@ -553,13 +553,13 @@ steps:
deploy-staging:
image: appleboy/drone-ssh
settings:
host: staging.cannabrands.com
host: staging.cannabrands.app
username: deploy
key:
from_secret: staging_ssh_key
script:
- cd /var/www/cannabrands
- docker pull code.cannabrands.com/cannabrands/hub:${CI_COMMIT_SHA:0:8}
- docker pull code.cannabrands.app/cannabrands/hub:${CI_COMMIT_SHA:0:8}
- docker-compose up -d
- docker exec cannabrands php artisan migrate --force
- docker exec cannabrands php artisan config:cache
@@ -582,7 +582,7 @@ steps:
- echo "To deploy to production:"
- echo " ssh cannabrands-prod"
- echo " cd /var/www/cannabrands"
- echo " docker pull code.cannabrands.com/cannabrands/hub:${CI_COMMIT_SHA:0:8}"
- echo " docker pull code.cannabrands.app/cannabrands/hub:${CI_COMMIT_SHA:0:8}"
- echo " docker-compose up -d"
- echo ""
- echo "⚠️ Remember: Check deployment checklist first!"

View File

@@ -102,7 +102,7 @@ Push to master → Woodpecker runs:
→ Build Docker image
→ Tag: cannabrands-hub:c165bf9 (commit SHA)
→ Tag: cannabrands-hub:latest
→ Push to code.cannabrands.com/cannabrands/hub
→ Push to code.cannabrands.app/cannabrands/hub
→ Image ready, no deployment yet
```
@@ -115,7 +115,7 @@ Push to master → Woodpecker runs:
### Phase 3: Auto-Deploy to Staging ⏭️
```
Push to master → Tests pass → Build image
→ Auto-deploy to staging.cannabrands.com
→ Auto-deploy to staging.cannabrands.app
→ Use staging environment variables
→ Manual QA testing
→ Production still manual
@@ -177,7 +177,7 @@ CMD ["php-fpm"]
### Staging Deployment:
```bash
# Pull the same image
docker pull code.cannabrands.com/cannabrands/hub:c165bf9
docker pull code.cannabrands.app/cannabrands/hub:c165bf9
# Run with staging environment
docker run \
@@ -186,13 +186,13 @@ docker run \
-e DB_DATABASE=cannabrands_staging \
-e APP_DEBUG=true \
-e MAIL_MAILER=log \
code.cannabrands.com/cannabrands/hub:c165bf9
code.cannabrands.app/cannabrands/hub:c165bf9
```
### Production Deployment:
```bash
# Pull THE EXACT SAME IMAGE
docker pull code.cannabrands.com/cannabrands/hub:c165bf9
docker pull code.cannabrands.app/cannabrands/hub:c165bf9
# Run with production environment
docker run \
@@ -201,7 +201,7 @@ docker run \
-e DB_DATABASE=cannabrands_production \
-e APP_DEBUG=false \
-e MAIL_MAILER=smtp \
code.cannabrands.com/cannabrands/hub:c165bf9
code.cannabrands.app/cannabrands/hub:c165bf9
```
**Key point**: Notice it's the **exact same image** (`c165bf9`), only environment variables differ.
@@ -218,7 +218,7 @@ docker run \
version: '3.8'
services:
app:
image: code.cannabrands.com/cannabrands/hub:latest
image: code.cannabrands.app/cannabrands/hub:latest
env_file:
- .env.staging # Staging-specific vars
ports:
@@ -253,7 +253,7 @@ secrets:
version: '3.8'
services:
app:
image: code.cannabrands.com/cannabrands/hub:c165bf9 # Specific SHA
image: code.cannabrands.app/cannabrands/hub:c165bf9 # Specific SHA
env_file:
- .env.production # Production-specific vars
ports:
@@ -301,7 +301,7 @@ spec:
spec:
containers:
- name: app
image: code.cannabrands.com/cannabrands/hub:c165bf9
image: code.cannabrands.app/cannabrands/hub:c165bf9
envFrom:
- configMapRef:
name: app-config-staging # Different per namespace
@@ -350,8 +350,8 @@ steps:
build-and-publish:
image: plugins/docker
settings:
registry: code.cannabrands.com
repo: code.cannabrands.com/cannabrands/hub
registry: code.cannabrands.app
repo: code.cannabrands.app/cannabrands/hub
tags:
- latest # Always overwrite
- ${CI_COMMIT_SHA:0:8} # Immutable SHA
@@ -384,7 +384,7 @@ Date: 2025-01-15 14:30:00 PST
Image: cannabrands-hub:c165bf9
Deployed by: jon@cannabrands.com
Approved by: compliance@cannabrands.com
Git commit: https://code.cannabrands.com/.../c165bf9
Git commit: https://code.cannabrands.app/.../c165bf9
Changes: Invoice picking workflow update
Tests passed: ✅ 28/28
Staging tested: ✅ 2 hours
@@ -424,7 +424,7 @@ Rollback image: cannabrands-hub:a1b2c3d
```bash
# On production server
ssh cannabrands-prod
docker pull code.cannabrands.com/cannabrands/hub:c165bf9
docker pull code.cannabrands.app/cannabrands/hub:c165bf9
docker-compose -f docker-compose.production.yml up -d
```
@@ -487,14 +487,14 @@ steps:
security-scan:
image: aquasec/trivy
commands:
- trivy image code.cannabrands.com/cannabrands/hub:${CI_COMMIT_SHA:0:8}
- trivy image code.cannabrands.app/cannabrands/hub:${CI_COMMIT_SHA:0:8}
```
### 4. Sign Images (Advanced)
Use Cosign to cryptographically sign images:
```bash
cosign sign code.cannabrands.com/cannabrands/hub:c165bf9
cosign sign code.cannabrands.app/cannabrands/hub:c165bf9
```
Compliance benefit: Prove image hasn't been tampered with.
@@ -507,10 +507,10 @@ Compliance benefit: Prove image hasn't been tampered with.
```bash
# List recent deployments
docker images code.cannabrands.com/cannabrands/hub
docker images code.cannabrands.app/cannabrands/hub
# Rollback to previous version
docker pull code.cannabrands.com/cannabrands/hub:a1b2c3d
docker pull code.cannabrands.app/cannabrands/hub:a1b2c3d
docker-compose -f docker-compose.production.yml up -d
```
@@ -531,7 +531,7 @@ deploy:
# Before risky deployment
git tag -a v1.5.2-stable -m "Last known good version"
docker tag cannabrands-hub:current cannabrands-hub:v1.5.2-stable
docker push code.cannabrands.com/cannabrands/hub:v1.5.2-stable
docker push code.cannabrands.app/cannabrands/hub:v1.5.2-stable
```
---

View File

@@ -41,7 +41,7 @@ The current implementation uses **filesystem caching** (Option 1 below) with the
### Option 1: Enable Repository Trust (Recommended)
**In Woodpecker UI:**
1. Go to: https://ci.cannabrands.com/repos/1/settings
1. Go to: https://ci.cannabrands.app/repos/1/settings
2. Click **"Settings"** tab
3. Enable **"Trusted"** toggle
4. Save settings
@@ -254,25 +254,25 @@ WORKDIR /woodpecker/src
**Build and push to Gitea:**
```bash
docker build -f docker/ci-php.Dockerfile -t code.cannabrands.com/cannabrands/ci-php:8.3 .
docker push code.cannabrands.com/cannabrands/ci-php:8.3
docker build -f docker/ci-php.Dockerfile -t code.cannabrands.app/cannabrands/ci-php:8.3 .
docker push code.cannabrands.app/cannabrands/ci-php:8.3
```
**Update `.woodpecker/.ci.yml`:**
```yaml
steps:
php-lint:
image: code.cannabrands.com/cannabrands/ci-php:8.3
image: code.cannabrands.app/cannabrands/ci-php:8.3
commands:
- find app routes database -name "*.php" -exec php -l {} \;
composer-install:
image: code.cannabrands.com/cannabrands/ci-php:8.3
image: code.cannabrands.app/cannabrands/ci-php:8.3
commands:
- composer install --no-interaction --prefer-dist --optimize-autoloader
code-style:
image: code.cannabrands.com/cannabrands/ci-php:8.3
image: code.cannabrands.app/cannabrands/ci-php:8.3
commands:
- ./vendor/bin/pint --test
```
@@ -323,4 +323,4 @@ steps:
**Enable caching when**: Team grows or build frequency increases
**Recommended method**: Repository trust + filesystem cache
To enable caching now, just go to https://ci.cannabrands.com/repos/1/settings and enable "Trusted".
To enable caching now, just go to https://ci.cannabrands.app/repos/1/settings and enable "Trusted".

View File

@@ -1,578 +0,0 @@
# Git Branching Strategy for Cannabrands
## Evolution of Your Workflow
Your branching strategy should evolve with your team size and customer base. This document outlines the transition path.
---
## Phase 0: Pre-Release (Current)
**Team Size:** 1-2 developers
**Customers:** None yet
**Goal:** Move fast, iterate quickly
### Current Workflow: Direct to Master
```
Local changes → Commit to master → Push → CI tests → (Optional: Deploy to dev)
```
**Commands:**
```bash
# Make changes
git add .
git commit -m "feat: add new feature"
git push origin master
```
**When this works:**
- ✅ Solo developer or tight 2-person team
- ✅ No customers depending on stability
- ✅ Fast iteration is priority #1
- ✅ Quick fixes needed immediately
**When to stop:**
- ❌ First paying customer signs up
- ❌ Team grows to 3+ developers
- ❌ Too many merge conflicts
- ❌ Need code review before deployment
---
## Phase 1: Feature Branches (Transition)
**Team Size:** 2-5 developers
**Customers:** First few customers OR approaching launch
**Goal:** Add safety through code review
### GitHub Flow: Feature Branches + PRs
```
master (stable, auto-deploys to dev)
Pull Requests (code review required)
feature branches (work in progress)
```
**Workflow:**
```bash
# 1. Create feature branch from master
git checkout master
git pull origin master
git checkout -b feature/add-payment-terms
# 2. Make changes
# ... edit files ...
git add .
git commit -m "feat: add payment term surcharge calculation"
# 3. Push feature branch
git push origin feature/add-payment-terms
# 4. Create Pull Request in Gitea
# - Go to Gitea UI
# - Click "New Pull Request"
# - Base: master, Compare: feature/add-payment-terms
# - Add description and assign reviewer
# 5. After approval, merge to master
# - Click "Merge" button in Gitea
# - Delete feature branch
# 6. Pull updated master
git checkout master
git pull origin master
git branch -d feature/add-payment-terms
```
**Branch Naming Conventions:**
```
feature/short-description # New features
fix/bug-description # Bug fixes
refactor/what-changed # Code refactoring
docs/what-documented # Documentation only
test/what-tested # Test additions
Examples:
feature/buyer-registration
fix/invoice-calculation-error
refactor/order-service
docs/deployment-guide
test/checkout-flow
```
**Commit Message Format:**
```
type(scope): subject
Examples:
feat(checkout): add payment term selection
fix(invoice): correct tax calculation for multi-state orders
refactor(orders): extract order validation logic
docs(readme): update local setup instructions
test(auth): add buyer login tests
```
**Benefits:**
- ✅ Code review before merging
- ✅ Catch bugs early
- ✅ Knowledge sharing across team
- ✅ Cleaner git history
- ✅ Can work on multiple features in parallel
**When to stop:**
- ❌ Team grows beyond 10 people
- ❌ Need to manage multiple versions
- ❌ Need longer release cycles
---
## Phase 2: Environment Branches (Mature)
**Team Size:** 5+ developers
**Customers:** Growing customer base
**Goal:** Staged rollout with stability
### Two-Branch Model: Develop + Master
```
master (production) ← deploys to app.cannabrands.com
Pull Requests (from develop, tested on staging)
develop (integration) ← deploys to staging.cannabrands.com
Pull Requests (from feature branches)
feature branches (work in progress)
```
**Workflow:**
```bash
# 1. Create feature branch from develop
git checkout develop
git pull origin develop
git checkout -b feature/bulk-order-import
# 2. Make changes and commit
git add .
git commit -m "feat(orders): add CSV bulk import"
git push origin feature/bulk-order-import
# 3. Create PR: feature → develop
# - Merging to develop auto-deploys to staging.cannabrands.com
# - Team tests on staging
# 4. When staging is stable, create PR: develop → master
# - Requires approval from team lead
# - Merging to master deploys to production
# 5. Regular develop → master promotions
# - Every 1-2 weeks
# - Or after major features are tested
```
**Branch Protection Rules:**
**Master Branch:**
- ✅ Require pull request reviews (1+ approvals)
- ✅ Require status checks to pass (CI tests)
- ✅ Require branches to be up to date
- ✅ Restrict who can push (admin only)
**Develop Branch:**
- ✅ Require pull request reviews (1+ approval)
- ✅ Require status checks to pass
- ⬜ Allow force pushes (optional)
**Setup in Gitea:**
```
Settings → Repository → Branches → Add Branch Protection Rule
- Branch name pattern: master
- Enable protection
- Require pull request reviews before merging
- Dismiss stale pull request approvals when new commits are pushed
- Require status checks to pass before merging
```
**Benefits:**
- ✅ Staging environment matches production
- ✅ Catch integration issues before customers see them
- ✅ Multiple features can be tested together
- ✅ Rollback is easier (master = last known good)
- ✅ QA team has stable environment to test
---
## Phase 3: Release Branches (Enterprise)
**Team Size:** 10+ developers
**Customers:** Large customer base, SLA commitments
**Goal:** Support multiple versions, scheduled releases
### Git Flow: Full Enterprise Model
```
master (production) ← hotfixes, release branches
release/v2.1.0 (release preparation)
develop (integration) ← feature branches
feature/* (work in progress)
```
**When you need this:**
- Multiple versions in production (e.g., self-hosted + SaaS)
- Need to support old versions
- Scheduled release cycles (monthly/quarterly)
- Regulatory requirements for change control
**This is probably overkill for most cannabis startups.**
---
## Hotfix Workflow (All Phases)
**When:** Critical bug in production needs immediate fix
### Quick Hotfix Process
```bash
# 1. Create hotfix branch from master
git checkout master
git pull origin master
git checkout -b hotfix/invoice-calculation-fix
# 2. Make MINIMAL changes to fix the bug
git add .
git commit -m "fix(invoice): correct tax calculation for CA"
# 3. Test locally
./vendor/bin/sail artisan test
# 4. Create PR to master (expedited review)
# - Mark as "HOTFIX - URGENT"
# - Get quick approval from team lead
# 5. After merging to master:
# - Deploy to production immediately
# - Backport fix to develop branch
git checkout develop
git merge master
git push origin develop
```
**Hotfix Rules:**
- ⚠️ Only for critical production bugs
- ⚠️ Must be small, focused changes
- ⚠️ Expedited review process
- ⚠️ Deploy ASAP after merge
---
## Recommended Transition Timeline
### Now → First Customer (Phase 0)
**Strategy:** Direct to master
**Why:** Fast iteration, no customer impact
```bash
# Your current workflow - keep doing this
git commit -am "feat: add new feature"
git push origin master
```
### First Customer → 5 Customers (Phase 1)
**Trigger:** First paying customer OR 3+ developers
**Strategy:** Feature branches with PRs
**Timeline:** Implement within 2 weeks of first customer
**Action Items:**
1. Create `CONTRIBUTING.md` with PR guidelines
2. Enable branch protection on master
3. Train team on PR workflow
4. Set up code review rotation
### 5+ Customers → Growth Phase (Phase 2)
**Trigger:**
- 5+ developers on team OR
- 50+ active customers OR
- Need for staging environment
**Strategy:** Develop + Master branches
**Timeline:** Plan 1 month for transition
**Action Items:**
1. Set up staging.cannabrands.com server
2. Create develop branch
3. Update CI/CD for both branches
4. Document new workflow for team
---
## Pull Request Best Practices
### Writing Good PRs
**Title Format:**
```
type(scope): brief description
Examples:
feat(orders): add bulk order import
fix(invoice): correct tax calculation
refactor(auth): simplify login flow
```
**Description Template:**
```markdown
## What Changed
Brief description of what this PR does
## Why
Explain the problem this solves or feature it adds
## How to Test
1. Go to /orders/import
2. Upload sample CSV
3. Verify orders are created correctly
## Screenshots (if UI changes)
[Attach screenshots]
## Checklist
- [ ] Tests added/updated
- [ ] Documentation updated
- [ ] Tested locally
- [ ] No merge conflicts
```
### Reviewing PRs
**What to look for:**
- ✅ Code solves the stated problem
- ✅ Tests cover new functionality
- ✅ Follows existing code style
- ✅ No obvious bugs or security issues
- ✅ Documentation is updated
**How to provide feedback:**
```markdown
## Blocking Issues (must fix before merge)
- [ ] Line 45: This will cause a division by zero error
## Suggestions (nice to have)
- Line 23: Consider extracting this to a helper method
- Could add a comment explaining this logic
## Praise (always include!)
- Great test coverage!
- Clean implementation of the CSV parser
```
### PR Etiquette
**Author:**
- Keep PRs small (<400 lines changed)
- Respond to feedback within 24 hours
- Don't merge your own PRs (unless emergency)
- Update PR if master changes
**Reviewer:**
- Review within 24 hours
- Be kind and constructive
- Ask questions instead of making demands
- Approve when ready (don't hold up progress)
---
## Merge Strategies
### Squash and Merge (Recommended for Feature Branches)
**What it does:** Combines all commits into one when merging
**Use when:**
- Feature branch has messy commit history
- Want clean master history
- PRs are self-contained features
**Gitea Setting:** "Squash and merge" button in PR
**Example:**
```
Before merge (feature branch):
- fix typo
- wip: add validation
- add tests
- fix tests
- update docs
After merge (master):
- feat(orders): add bulk import with CSV validation (#42)
```
### Regular Merge (For Long-Running Branches)
**What it does:** Preserves all commits and creates merge commit
**Use when:**
- Merging develop → master
- Want to preserve detailed history
- Multiple developers collaborated on branch
---
## Common Workflows
### Starting New Feature
```bash
# Update master
git checkout master
git pull origin master
# Create feature branch
git checkout -b feature/product-variants
# Make changes
# ... work work work ...
# Commit regularly (small commits)
git add .
git commit -m "feat(products): add variant model"
git push origin feature/product-variants
# Create PR when ready
```
### Updating Feature Branch with Latest Master
```bash
# Your feature branch is behind master
git checkout feature/product-variants
# Option 1: Merge master into feature (preserves commits)
git merge origin/master
git push origin feature/product-variants
# Option 2: Rebase on master (cleaner history)
git rebase origin/master
git push -f origin feature/product-variants # Force push needed after rebase
```
**When to use each:**
- **Merge:** Safer, preserves history, good for collaboration
- **Rebase:** Cleaner history, good for solo feature branches
### Fixing Merge Conflicts
```bash
# Pull latest master
git checkout master
git pull origin master
# Try to merge (conflict!)
git checkout feature/product-variants
git merge master
# Git shows conflicts
# CONFLICT (content): Merge conflict in app/Models/Product.php
# Open conflicted file
nano app/Models/Product.php
# Look for conflict markers:
<<<<<<< HEAD
// Your changes
=======
// Changes from master
>>>>>>> master
# Resolve conflicts, remove markers
# Save file
# Mark as resolved
git add app/Models/Product.php
git commit -m "fix: resolve merge conflicts with master"
git push origin feature/product-variants
```
---
## Branch Cleanup
### Deleting Merged Feature Branches
```bash
# After PR is merged, delete local branch
git branch -d feature/product-variants
# Delete remote branch (Gitea can auto-delete)
git push origin --delete feature/product-variants
# Remove stale remote references
git remote prune origin
# See all branches
git branch -a
```
### Finding Old Branches
```bash
# List branches by last commit date
git for-each-ref --sort=-committerdate refs/heads/
# Delete branches older than 30 days
git branch --merged master | grep -v "master" | xargs git branch -d
```
---
## Summary: Your Transition Path
### Today (Pre-Release)
**Direct to master** - current setup
- Fast iteration
- No PR overhead
- Good for solo/pair development
### First Customer (In 1-3 Months)
🔜 **Feature branches + PRs**
- Add code review
- Protect master branch
- Enable CI on PRs
### Growing Team (In 6-12 Months)
🔜 **Develop + Master branches**
- Add staging environment
- Staged rollouts
- Better stability
---
## Quick Reference
| Phase | Team Size | Branches | Deploy To | When |
|-------|-----------|----------|-----------|------|
| 0: Pre-release | 1-2 | master | dev | Now |
| 1: Feature branches | 2-5 | master + feature/* | dev | First customer |
| 2: Environment branches | 5-10 | master + develop + feature/* | staging + prod | Growing team |
| 3: Release branches | 10+ | master + develop + release/* + feature/* | Multiple envs | Enterprise |
**Recommended for Cannabrands:**
- **Now:** Phase 0 (direct to master)
- **Next:** Phase 1 (feature branches) - within 2 weeks of first customer
- **Future:** Phase 2 (develop branch) - when team grows to 5+
**Key principle:** *"Choose the simplest workflow that meets your current needs. You can always add complexity later."*

View File

@@ -32,7 +32,7 @@ Treat your **pre-release testing environment** as if it were production, even th
**Environment Setup:**
```
master branch → CI tests pass → Build image → Deploy to dev.cannabrands.com
master branch → CI tests pass → Build image → Deploy to dev.cannabrands.app
Team tests here (internal only)
```
@@ -52,19 +52,19 @@ master branch → CI tests pass → Build image → Deploy to dev.cannabrands.co
---
## Setting Up dev.cannabrands.com
## Setting Up dev.cannabrands.app
### Step 1: Server Preparation
**Server Requirements:**
- Ubuntu 22.04+ or similar
- Docker + Docker Compose installed
- Domain pointed to server (dev.cannabrands.com)
- Domain pointed to server (dev.cannabrands.app)
- SSL certificate (Let's Encrypt)
**Create deployment user:**
```bash
# On dev.cannabrands.com server
# On dev.cannabrands.app server
sudo adduser deployer
sudo usermod -aG docker deployer
sudo mkdir -p /var/www/cannabrands
@@ -77,7 +77,7 @@ sudo chown deployer:deployer /var/www/cannabrands
ssh-keygen -t ed25519 -C "woodpecker-deploy" -f ~/.ssh/cannabrands_deploy
# Add public key to server
ssh deployer@dev.cannabrands.com
ssh deployer@dev.cannabrands.app
mkdir -p ~/.ssh
nano ~/.ssh/authorized_keys
# Paste public key, save
@@ -95,7 +95,7 @@ nano ~/.ssh/authorized_keys
### Step 2: Create Deployment Docker Compose
**On dev.cannabrands.com server:**
**On dev.cannabrands.app server:**
```bash
cd /var/www/cannabrands
nano docker-compose.yml
@@ -107,7 +107,7 @@ version: '3.8'
services:
app:
image: code.cannabrands.com/cannabrands/hub:latest
image: code.cannabrands.app/cannabrands/hub:latest
container_name: cannabrands_app
restart: unless-stopped
ports:
@@ -115,7 +115,7 @@ services:
environment:
- APP_ENV=development
- APP_DEBUG=true
- APP_URL=https://dev.cannabrands.com
- APP_URL=https://dev.cannabrands.app
- DB_HOST=postgres
- DB_PORT=5432
- DB_DATABASE=cannabrands
@@ -168,7 +168,7 @@ APP_NAME="Cannabrands Dev"
APP_ENV=development
APP_KEY=base64:YOUR_KEY_HERE
APP_DEBUG=true
APP_URL=https://dev.cannabrands.com
APP_URL=https://dev.cannabrands.app
DB_CONNECTION=pgsql
DB_HOST=postgres
@@ -204,8 +204,8 @@ steps:
build-image:
image: woodpeckerci/plugin-docker-buildx
settings:
registry: code.cannabrands.com
repo: code.cannabrands.com/cannabrands/hub
registry: code.cannabrands.app
repo: code.cannabrands.app/cannabrands/hub
username:
from_secret: gitea_username
password:
@@ -222,12 +222,12 @@ steps:
deploy-dev:
image: appleboy/drone-ssh
settings:
host: dev.cannabrands.com
host: dev.cannabrands.app
username: deployer
key:
from_secret: dev_ssh_key
script:
- echo "🚀 Deploying to dev.cannabrands.com..."
- echo "🚀 Deploying to dev.cannabrands.app..."
- cd /var/www/cannabrands
- docker compose pull app
- docker compose up -d app
@@ -238,7 +238,7 @@ steps:
- docker compose exec -T app php artisan route:cache
- docker compose exec -T app php artisan view:cache
- echo "✅ Deployment complete!"
- echo "🌐 Visit https://dev.cannabrands.com"
- echo "🌐 Visit https://dev.cannabrands.app"
when:
branch: master
event: push
@@ -252,7 +252,7 @@ steps:
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "✅ DEV ENVIRONMENT UPDATED"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "URL: https://dev.cannabrands.com"
echo "URL: https://dev.cannabrands.app"
echo "Commit: ${CI_COMMIT_SHA:0:8}"
echo "Author: ${CI_COMMIT_AUTHOR}"
echo "Message: ${CI_COMMIT_MESSAGE}"
@@ -373,7 +373,7 @@ git push origin feature/add-payment-terms
## Dev Environment
**URL:** https://dev.cannabrands.com
**URL:** https://dev.cannabrands.app
**Status:** 🟡 Active Development
- Updates automatically on every master push
@@ -470,8 +470,8 @@ Questions? Ask in #development Slack channel or email dev@cannabrands.com
**What changes:**
1. **Rename environments:**
- `dev.cannabrands.com` → stays as dev (unstable)
- Add `staging.cannabrands.com` → stable pre-release testing
- `dev.cannabrands.app` → stays as dev (unstable)
- Add `staging.cannabrands.app` → stable pre-release testing
- Add `app.cannabrands.com` → production (customers)
2. **Update git workflow:**
@@ -527,7 +527,7 @@ Questions? Ask in #development Slack channel or email dev@cannabrands.com
```bash
# SSH into dev server
ssh deployer@dev.cannabrands.com
ssh deployer@dev.cannabrands.app
# Navigate to app directory
cd /var/www/cannabrands
@@ -564,7 +564,7 @@ docker images | grep cannabrands
```bash
# Pull previous commit's image
docker pull code.cannabrands.com/cannabrands/hub:PREVIOUS_SHA
docker pull code.cannabrands.app/cannabrands/hub:PREVIOUS_SHA
# Update docker-compose.yml to use specific tag
docker compose up -d app
@@ -586,10 +586,10 @@ docker compose exec app php artisan migrate --seed
**Your current setup:**
- ✅ CI tests pass on every push
- ⬜ Build Docker images (add this)
- ⬜ Auto-deploy to dev.cannabrands.com (add this)
- ⬜ Auto-deploy to dev.cannabrands.app (add this)
**What this enables:**
- Teammates can test at https://dev.cannabrands.com
- Teammates can test at https://dev.cannabrands.app
- Always reflects latest master
- Fast iteration (deploy in ~2 minutes)
- No manual deployment needed

View File

@@ -11,10 +11,10 @@ Once you implement production deployments, Woodpecker will:
Your images will be available at:
```
code.cannabrands.com/cannabrands/hub
code.cannabrands.app/cannabrands/hub
```
**View packages**: https://code.cannabrands.com/Cannabrands/hub/-/packages
**View packages**: https://code.cannabrands.app/Cannabrands/hub/-/packages
## Step 1: Enable Gitea Package Registry
@@ -22,7 +22,7 @@ First, verify the registry is enabled on your Gitea instance:
1. **Check as admin**: Admin → Site Administration → Configuration
2. **Look for**: `[packages]` section with `ENABLED = true`
3. **Test**: Visit https://code.cannabrands.com/-/packages
3. **Test**: Visit https://code.cannabrands.app/-/packages
If not enabled, ask your Gitea admin to enable it in `app.ini`:
```ini
@@ -44,7 +44,7 @@ Create a token for Woodpecker to authenticate with Gitea:
Configure Woodpecker to authenticate with your Gitea registry:
1. **Go to**: https://ci.cannabrands.com/repos/1/settings/secrets
1. **Go to**: https://ci.cannabrands.app/repos/1/settings/secrets
2. **Add secrets**:
- Name: `gitea_username`, Value: Your Gitea username
- Name: `gitea_token`, Value: Personal access token from Step 2
@@ -61,8 +61,8 @@ steps:
build-and-publish:
image: plugins/docker
settings:
registry: code.cannabrands.com
repo: code.cannabrands.com/cannabrands/hub
registry: code.cannabrands.app
repo: code.cannabrands.app/cannabrands/hub
tags:
- latest
- ${CI_COMMIT_SHA:0:8}
@@ -136,15 +136,15 @@ Once images are published, you can pull them on your production servers:
```bash
# Login to Gitea registry
docker login code.cannabrands.com
docker login code.cannabrands.app
# Username: your-gitea-username
# Password: your-personal-access-token
# Pull latest image
docker pull code.cannabrands.com/cannabrands/hub:latest
docker pull code.cannabrands.app/cannabrands/hub:latest
# Or pull specific commit
docker pull code.cannabrands.com/cannabrands/hub:bef77df8
docker pull code.cannabrands.app/cannabrands/hub:bef77df8
```
## Image Tagging Strategy
@@ -218,8 +218,8 @@ steps:
build-and-publish:
image: plugins/docker
settings:
registry: code.cannabrands.com
repo: code.cannabrands.com/cannabrands/hub
registry: code.cannabrands.app
repo: code.cannabrands.app/cannabrands/hub
tags:
- latest
- ${CI_COMMIT_SHA:0:8}
@@ -236,7 +236,7 @@ steps:
notify-deploy:
image: alpine:latest
commands:
- echo "✅ New image published: code.cannabrands.com/cannabrands/hub:${CI_COMMIT_SHA:0:8}"
- echo "✅ New image published: code.cannabrands.app/cannabrands/hub:${CI_COMMIT_SHA:0:8}"
- echo "Ready for deployment to production!"
when:
- branch: master
@@ -271,8 +271,8 @@ services:
- Subsequent builds will work fine
**Images not appearing in Gitea packages**
- Check Gitea packages are enabled: https://code.cannabrands.com/-/packages
- Verify registry URL is `code.cannabrands.com` (not `ci.cannabrands.com`)
- Check Gitea packages are enabled: https://code.cannabrands.app/-/packages
- Verify registry URL is `code.cannabrands.app` (not `ci.cannabrands.app`)
## Next Steps

View File

@@ -85,7 +85,7 @@ git push origin 2025.11.3
### Step 3: Wait for CI Build (2-4 minutes)
Watch at: `code.cannabrands.com/cannabrands/hub/pipelines`
Watch at: `code.cannabrands.app/cannabrands/hub/pipelines`
CI will automatically:
- Run tests
@@ -113,7 +113,7 @@ git push origin master
```bash
# Deploy specific version
kubectl set image deployment/cannabrands \
app=code.cannabrands.com/cannabrands/hub:2025.11.3
app=code.cannabrands.app/cannabrands/hub:2025.11.3
# Watch deployment
kubectl rollout status deployment/cannabrands
@@ -131,7 +131,7 @@ kubectl get pods
```bash
# Option 1: Rollback to previous version
kubectl set image deployment/cannabrands \
app=code.cannabrands.com/cannabrands/hub:2025.11.2
app=code.cannabrands.app/cannabrands/hub:2025.11.2
# Option 2: Kubernetes automatic rollback
kubectl rollout undo deployment/cannabrands
@@ -154,7 +154,7 @@ git push origin 2025.11.4
# 4. Deploy when confident
kubectl set image deployment/cannabrands \
app=code.cannabrands.com/cannabrands/hub:2025.11.4
app=code.cannabrands.app/cannabrands/hub:2025.11.4
```
---
@@ -170,7 +170,7 @@ master → Branch tracking
**Use in K3s dev/staging:**
```yaml
image: code.cannabrands.com/cannabrands/hub:latest-dev
image: code.cannabrands.app/cannabrands/hub:latest-dev
imagePullPolicy: Always
```
@@ -182,7 +182,7 @@ stable → Latest production release
**Use in K3s production:**
```yaml
image: code.cannabrands.com/cannabrands/hub:2025.11.3
image: code.cannabrands.app/cannabrands/hub:2025.11.3
imagePullPolicy: IfNotPresent
```
@@ -214,7 +214,7 @@ docker build -t cannabrands:test .
### View CI Status
```bash
# Visit Woodpecker
open https://code.cannabrands.com/cannabrands/hub/pipelines
open https://code.cannabrands.app/cannabrands/hub/pipelines
# Or check latest build
# (Visit Gitea → Repository → Pipelines)
@@ -227,7 +227,7 @@ open https://code.cannabrands.com/cannabrands/hub/pipelines
### CI Build Failing
```bash
# Check Woodpecker logs
# Visit: code.cannabrands.com/cannabrands/hub/pipelines
# Visit: code.cannabrands.app/cannabrands/hub/pipelines
# Run tests locally first
./vendor/bin/sail artisan test
@@ -325,8 +325,8 @@ Before deploying:
- Pair with senior dev for first release
### CI/CD
- Woodpecker: `code.cannabrands.com/cannabrands/hub`
- Gitea: `code.cannabrands.com/cannabrands/hub`
- Woodpecker: `code.cannabrands.app/cannabrands/hub`
- Gitea: `code.cannabrands.app/cannabrands/hub`
- K3s Dashboard: (ask devops for link)
---
@@ -334,13 +334,13 @@ Before deploying:
## Important URLs
**Code Repository:**
https://code.cannabrands.com/cannabrands/hub
https://code.cannabrands.app/cannabrands/hub
**CI/CD Pipeline:**
https://code.cannabrands.com/cannabrands/hub/pipelines
https://code.cannabrands.app/cannabrands/hub/pipelines
**Container Registry:**
https://code.cannabrands.com/-/packages/container/cannabrands%2Fhub
https://code.cannabrands.app/-/packages/container/cannabrands%2Fhub
**Documentation:**
`.woodpecker/` directory in repository
@@ -393,7 +393,7 @@ Closes #42"
| Deploy | `kubectl set image deployment/cannabrands app=...:2025.11.1` |
| Rollback | `kubectl set image deployment/cannabrands app=...:2025.11.0` |
| Check version | `kubectl get deployment cannabrands -o jsonpath='{.spec.template.spec.containers[0].image}'` |
| View builds | Visit `code.cannabrands.com/cannabrands/hub/pipelines` |
| View builds | Visit `code.cannabrands.app/cannabrands/hub/pipelines` |
---

View File

@@ -33,7 +33,7 @@ git push origin master
2. Tests run (PHP lint, Pint, PHPUnit)
3. Docker image builds (if tests pass)
4. Tagged as: latest-dev, dev-c658193, master
5. Pushed to code.cannabrands.com/cannabrands/hub
5. Pushed to code.cannabrands.app/cannabrands/hub
6. Available in K3s dev namespace (manual or auto-pull)
```
@@ -47,7 +47,7 @@ git push origin master
**Use in K3s:**
```yaml
# dev/staging namespace
image: code.cannabrands.com/cannabrands/hub:latest-dev
image: code.cannabrands.app/cannabrands/hub:latest-dev
imagePullPolicy: Always # Always pull newest
```
@@ -81,7 +81,7 @@ git push origin 2025.11.1
**Use in K3s:**
```yaml
# production namespace
image: code.cannabrands.com/cannabrands/hub:2025.11.1
image: code.cannabrands.app/cannabrands/hub:2025.11.1
imagePullPolicy: IfNotPresent # Pin to specific version
```
@@ -212,7 +212,7 @@ git push origin master
./vendor/bin/sail artisan test
# Check CI is green
# Visit: code.cannabrands.com/cannabrands/hub/pipelines
# Visit: code.cannabrands.app/cannabrands/hub/pipelines
# Test in staging/dev environment
# Verify key workflows work
@@ -264,12 +264,12 @@ git push origin 2025.11.3
```bash
# Watch Woodpecker build
# Visit: code.cannabrands.com/cannabrands/hub/pipelines
# Visit: code.cannabrands.app/cannabrands/hub/pipelines
# Wait for success (2-4 minutes)
# CI will build and push:
# - code.cannabrands.com/cannabrands/hub:2025.11.3
# - code.cannabrands.com/cannabrands/hub:stable
# - code.cannabrands.app/cannabrands/hub:2025.11.3
# - code.cannabrands.app/cannabrands/hub:stable
```
#### 5. Deploy to Production (When Ready)
@@ -277,7 +277,7 @@ git push origin 2025.11.3
```bash
# Deploy new version
kubectl set image deployment/cannabrands \
app=code.cannabrands.com/cannabrands/hub:2025.11.3
app=code.cannabrands.app/cannabrands/hub:2025.11.3
# Watch rollout
kubectl rollout status deployment/cannabrands
@@ -328,11 +328,11 @@ git push origin master
```bash
# Option 1: Rollback to specific version
kubectl set image deployment/cannabrands \
app=code.cannabrands.com/cannabrands/hub:2025.11.2
app=code.cannabrands.app/cannabrands/hub:2025.11.2
# Option 2: Use previous stable
kubectl set image deployment/cannabrands \
app=code.cannabrands.com/cannabrands/hub:stable
app=code.cannabrands.app/cannabrands/hub:stable
# Note: 'stable' is updated on every release
# So if you just deployed 2025.11.3, 'stable' points to 2025.11.3
@@ -367,7 +367,7 @@ git push origin 2025.11.4
# Deploy
kubectl set image deployment/cannabrands \
app=code.cannabrands.com/cannabrands/hub:2025.11.4
app=code.cannabrands.app/cannabrands/hub:2025.11.4
```
---

View File

@@ -4,9 +4,9 @@
**Current tagging strategy:**
```
code.cannabrands.com/cannabrands/hub:latest # Always changes
code.cannabrands.com/cannabrands/hub:c658193 # Commit SHA (meaningless)
code.cannabrands.com/cannabrands/hub:master # Branch name (changes)
code.cannabrands.app/cannabrands/hub:latest # Always changes
code.cannabrands.app/cannabrands/hub:c658193 # Commit SHA (meaningless)
code.cannabrands.app/cannabrands/hub:master # Branch name (changes)
```
**Issues:**
@@ -143,8 +143,8 @@ The CI pipeline now builds images with version metadata for both development and
build-image-dev:
image: woodpeckerci/plugin-docker-buildx
settings:
registry: code.cannabrands.com
repo: code.cannabrands.com/cannabrands/hub
registry: code.cannabrands.app
repo: code.cannabrands.app/cannabrands/hub
tags:
- dev # Latest dev build
- sha-${CI_COMMIT_SHA:0:7} # Commit SHA
@@ -170,13 +170,13 @@ build-image-release:
**Result:**
```
# Development push to master
code.cannabrands.com/cannabrands/hub:dev
code.cannabrands.com/cannabrands/hub:sha-c658193
code.cannabrands.com/cannabrands/hub:master
code.cannabrands.app/cannabrands/hub:dev
code.cannabrands.app/cannabrands/hub:sha-c658193
code.cannabrands.app/cannabrands/hub:master
# Release (git tag 2025.10.1)
code.cannabrands.com/cannabrands/hub:2025.10.1 # Specific version
code.cannabrands.com/cannabrands/hub:latest # Latest stable
code.cannabrands.app/cannabrands/hub:2025.10.1 # Specific version
code.cannabrands.app/cannabrands/hub:latest # Latest stable
```
---
@@ -243,11 +243,11 @@ git checkout c658193
```bash
# Option 1: Rollback to specific version (recommended)
kubectl set image deployment/cannabrands \
app=code.cannabrands.com/cannabrands/hub:v1.2.2
app=code.cannabrands.app/cannabrands/hub:v1.2.2
# Option 2: Rollback to last stable
kubectl set image deployment/cannabrands \
app=code.cannabrands.com/cannabrands/hub:stable
app=code.cannabrands.app/cannabrands/hub:stable
# Option 3: Kubernetes rollback (uses previous deployment)
kubectl rollout undo deployment/cannabrands
@@ -281,7 +281,7 @@ cat CHANGELOG.md
# 5. Deploy specific version
kubectl set image deployment/cannabrands \
app=code.cannabrands.com/cannabrands/hub:v1.2.1
app=code.cannabrands.app/cannabrands/hub:v1.2.1
```
---
@@ -357,7 +357,7 @@ audit-deployment:
```
Developer → Commit to master → CI tests → Build dev image
code.cannabrands.com/cannabrands/hub:dev-COMMIT
code.cannabrands.app/cannabrands/hub:dev-COMMIT
Deploy to dev/staging (optional)
```
@@ -486,7 +486,7 @@ spec:
spec:
containers:
- name: app
image: code.cannabrands.com/cannabrands/hub:v1.2.3
image: code.cannabrands.app/cannabrands/hub:v1.2.3
imagePullPolicy: IfNotPresent # Don't pull if tag exists
ports:
- containerPort: 80
@@ -535,7 +535,7 @@ git push origin master
# 5. Deploy to production (manual)
kubectl set image deployment/cannabrands \
app=code.cannabrands.com/cannabrands/hub:v1.3.0
app=code.cannabrands.app/cannabrands/hub:v1.3.0
```
### Emergency Rollback
@@ -546,7 +546,7 @@ kubectl rollout undo deployment/cannabrands
# Or specific version
kubectl set image deployment/cannabrands \
app=code.cannabrands.com/cannabrands/hub:v1.2.3
app=code.cannabrands.app/cannabrands/hub:v1.2.3
# Verify
kubectl rollout status deployment/cannabrands

View File

@@ -3,223 +3,223 @@
### Bug Fixes
* add background color to Choices.js dropdown items ([fcf3689](https://code.cannabrands.com/Cannabrands/hub/commit/fcf36893d9750b33f087f750623f7a34468a5a15))
* add background to nested choices list container ([36a66d6](https://code.cannabrands.com/Cannabrands/hub/commit/36a66d65e2124d830cb40011c5116e28b56b463b))
* add high-specificity selectors for dropdown background ([c7de8d5](https://code.cannabrands.com/Cannabrands/hub/commit/c7de8d5bbe002b8c65bcfe51d279c1fc16e6476a))
* add libpq-dev for PostgreSQL extension and show build output ([e800eab](https://code.cannabrands.com/Cannabrands/hub/commit/e800eab40ea75d6057db837dbca8c26a031001a3))
* add missing Action import in LatestNotifications widget ([3b12568](https://code.cannabrands.com/Cannabrands/hub/commit/3b1256801805e608d7475fe5bbf0efd317663852))
* add missing compliance document methods to Business model ([0c3a935](https://code.cannabrands.com/Cannabrands/hub/commit/0c3a9350d9b74ad18e18f3125c787bf3f521ee55))
* add null user checks to OrderNotificationService ([a81c9a0](https://code.cannabrands.com/Cannabrands/hub/commit/a81c9a09ff71f0fb126220d18003bcff77e7e2fb))
* add overflow-visible to parent containers for dropdown menus ([dd5b7d2](https://code.cannabrands.com/Cannabrands/hub/commit/dd5b7d2b97679ea93f99df9e629e910ba6a09163))
* add z-index and opacity to dropdown to prevent see-through ([1991941](https://code.cannabrands.com/Cannabrands/hub/commit/1991941c4d8fc1a065188352cca0de3cdef08f1f))
* apply conditional dropdown-top to invoices table last row ([8e41203](https://code.cannabrands.com/Cannabrands/hub/commit/8e412031ea84fa350b221f90290de90ed6108fb4))
* broken product images on order show page ([7af3403](https://code.cannabrands.com/Cannabrands/hub/commit/7af34031872e873389a61e9889fc8959ebe0d2df))
* center empty state icon above 'No Locations Yet' text ([5041fec](https://code.cannabrands.com/Cannabrands/hub/commit/5041fece20f7523dd30414950dedd1bceeb28c20))
* change notification actions to redirect instead of returning JSON ([16621ce](https://code.cannabrands.com/Cannabrands/hub/commit/16621ce01c51436a3cdf2244623b559477bc1fac))
* change notification ID type from int to string for UUID support ([a53ce10](https://code.cannabrands.com/Cannabrands/hub/commit/a53ce10864bcf43798a9b10207684f4622c6e5d0))
* connect Analytics navigation link to actual route ([592a05d](https://code.cannabrands.com/Cannabrands/hub/commit/592a05d927715bcdc6acb72173a5165857ee73b5))
* convert business license type to dropdown in Filament admin ([95764a1](https://code.cannabrands.com/Cannabrands/hub/commit/95764a1484b459aa345488d6ea025eafd39aa7ba))
* correct anime.js import path for ES modules ([67e1ed2](https://code.cannabrands.com/Cannabrands/hub/commit/67e1ed270838fb8d67f1890f34e24d020be85ec7))
* correct brand colors column name from brand_colors to colors ([ba4bd33](https://code.cannabrands.com/Cannabrands/hub/commit/ba4bd33924d81561e8fdc44428f3e27186d83479))
* correct Business model relationships and remove non-existent fields ([911865e](https://code.cannabrands.com/Cannabrands/hub/commit/911865e48fe47df45b23d4917812bacd4dc165ee))
* correct business relationship reference in buyer invoice view (Bug [#9](https://code.cannabrands.com/Cannabrands/hub/issues/9)) ([0f3939c](https://code.cannabrands.com/Cannabrands/hub/commit/0f3939c76a04b74195727a2b7ebf34bf1216d1df))
* correct Company-Location relationship foreign key ([5b1969c](https://code.cannabrands.com/Cannabrands/hub/commit/5b1969c0e4d00dcbf615940bb92d9e5642d22be8))
* correct data format for ApexCharts treemaps and Cal-heatmap ([080e1c5](https://code.cannabrands.com/Cannabrands/hub/commit/080e1c5859bf1b49e01a80a0ff738bb41f3fe3b6))
* correct database column names and currency handling in analytics ([b0c7b2d](https://code.cannabrands.com/Cannabrands/hub/commit/b0c7b2d5559199574a559ef0046f3bed9212bfb8))
* correct database references from MySQL to PostgreSQL in README ([3a4fe0a](https://code.cannabrands.com/Cannabrands/hub/commit/3a4fe0ae5fe4c6f518a499e75427e43881b75100))
* correct invoice-brand relationship path (Bug [#9](https://code.cannabrands.com/Cannabrands/hub/issues/9)) ([b1dcb36](https://code.cannabrands.com/Cannabrands/hub/commit/b1dcb363cda527dca0284e61ae801b969e68405f))
* correct picking ticket complete route name (Bug [#8](https://code.cannabrands.com/Cannabrands/hub/issues/8)) ([f0596b2](https://code.cannabrands.com/Cannabrands/hub/commit/f0596b2a839c0ca8be138c0189515d29cf8643d4))
* correct picking ticket route name in seller order view ([a41cdf1](https://code.cannabrands.com/Cannabrands/hub/commit/a41cdf17586f1d65977744d7c7286906279c7ac9)), closes [#7](https://code.cannabrands.com/Cannabrands/hub/issues/7)
* correct Section import for Filament 4 Schema API ([26583b1](https://code.cannabrands.com/Cannabrands/hub/commit/26583b13ddd67a692970934aad882c9ec56251a6))
* correct user_type default and invoice business_id bug ([559d705](https://code.cannabrands.com/Cannabrands/hub/commit/559d7058507480b855f4a38d3b9dcadfdfac6254))
* force Choices.js dropdown to stay closed on page load ([9ddc883](https://code.cannabrands.com/Cannabrands/hub/commit/9ddc88343e24b8d4b7fa42df9dd0eacff2c7d4cd))
* handle null user in invoice show view for manual invoices ([ad2d7f8](https://code.cannabrands.com/Cannabrands/hub/commit/ad2d7f8fda1d27683158e1dd4edf0c01dbdedb5a))
* handle null user in picking ticket view ([92cbc21](https://code.cannabrands.com/Cannabrands/hub/commit/92cbc219eb2dd9c75a70976109ed74f385f8a80c))
* handle null user in seller invoices index view ([7e1c6a0](https://code.cannabrands.com/Cannabrands/hub/commit/7e1c6a0743a413b76b09ad9164134f13a0967d26))
* handle null user in seller order show view for manual invoices ([a772502](https://code.cannabrands.com/Cannabrands/hub/commit/a7725025f987fb666f927bcfe4afbc56188ef3ad))
* improve dark mode text readability with white text ([9264b8b](https://code.cannabrands.com/Cannabrands/hub/commit/9264b8bf1844ab2379067e9b3a6c17b90cc4ddea)), closes [#e3f1eb](https://code.cannabrands.com/Cannabrands/hub/issues/e3f1eb) [#ffffff](https://code.cannabrands.com/Cannabrands/hub/issues/ffffff)
* improve UX when no contacts exist for invoice customer ([5c7d9e6](https://code.cannabrands.com/Cannabrands/hub/commit/5c7d9e6c18e9d82f08f3f789200d7ec4ac04cf67))
* install PHP intl extension and dependencies for Filament ([0ef61eb](https://code.cannabrands.com/Cannabrands/hub/commit/0ef61eb4d10dc9af2c7228cbb8d7a8c216d0fe72))
* make Choices.js vendor dropdown compatible with DaisyUI theme ([a032d98](https://code.cannabrands.com/Cannabrands/hub/commit/a032d98ab5e5d55614a0515deadca00663f6ca95))
* make invoice creation page header theme-aware for dark mode ([5940ed1](https://code.cannabrands.com/Cannabrands/hub/commit/5940ed189f4be97b796395817c1d64338a53264f))
* make locations page header theme-aware and add icon ([91fcf1e](https://code.cannabrands.com/Cannabrands/hub/commit/91fcf1e401a96d1f1fea6e309ddd38d441df5818))
* make vendor dropdown reactive to dark mode toggle ([4b62fab](https://code.cannabrands.com/Cannabrands/hub/commit/4b62fab96f2589fa082a15be8fd4650d4dbb7fc1))
* move postgres to services section and add Composer caching ([bef77df](https://code.cannabrands.com/Cannabrands/hub/commit/bef77df1f6a0d35f3ed170b37f41d3c022106b68))
* only show 'awaiting buyer approval' after invoice is sent ([ea6d2ef](https://code.cannabrands.com/Cannabrands/hub/commit/ea6d2ef2d90feabead3bae5ad06a52198180b3dd))
* prevent Choices.js dropdown auto-opening and clean up single-select display ([36b7e5e](https://code.cannabrands.com/Cannabrands/hub/commit/36b7e5e3d63c80645a732123b4b2a696224e89b8))
* prevent dropdown menu clipping in table rows ([b831dd4](https://code.cannabrands.com/Cannabrands/hub/commit/b831dd4eb5a263ae8aa33132485fd4ced50814da))
* remove [@apply](https://code.cannabrands.com/apply) directives causing invalid CSS variables ([6409436](https://code.cannabrands.com/Cannabrands/hub/commit/6409436f339c8fd329d73dccf1ed2775bcbad5ad))
* remove Alpine x-for from vendor select to fix scope error ([5dc5379](https://code.cannabrands.com/Cannabrands/hub/commit/5dc5379f7023f95211116f723bc256883bc1fcdb))
* remove Alpine.js code and restore working toggle implementation ([2a8f072](https://code.cannabrands.com/Cannabrands/hub/commit/2a8f07200f3de3eea61fe84f712ea0c01bdc2760))
* remove Alpine.js code causing text rendering issues ([4e1706d](https://code.cannabrands.com/Cannabrands/hub/commit/4e1706dae032dc1acb17845494d3a2a8c098145b))
* remove Canna brand - now 12 brands total ([d228dd1](https://code.cannabrands.com/Cannabrands/hub/commit/d228dd1ca75a995e6fb7b8f4e807e9db34a74c45))
* remove Cannabrands as brand - it's a company only ([59db949](https://code.cannabrands.com/Cannabrands/hub/commit/59db949ca0210f518b752c745841183b8c8321aa))
* remove HtmlString label causing text overflow on toggles ([eed5e57](https://code.cannabrands.com/Cannabrands/hub/commit/eed5e570b930c3e9d4d91075b7608d794ea4a7b1))
* remove images column conflict with images() relationship ([79dbb2b](https://code.cannabrands.com/Cannabrands/hub/commit/79dbb2b370fae2ce2eade352da57ab77e487fcf9)), closes [#5](https://code.cannabrands.com/Cannabrands/hub/issues/5)
* remove inappropriate email availability checking from login form ([56b9a32](https://code.cannabrands.com/Cannabrands/hub/commit/56b9a32d1e5e8f3b0bb00b2133e44344e0d7d392))
* remove invalid Tab::description() method call ([fac1f45](https://code.cannabrands.com/Cannabrands/hub/commit/fac1f459e478ca922d524b04ba79292fce98e7ad))
* remove non-existent locations relationship from change fulfillment modal ([8174aad](https://code.cannabrands.com/Cannabrands/hub/commit/8174aadd273360a9e62a83d58070db2b320fa9f9))
* replace $order->company with $order->business in buyer order view ([7751604](https://code.cannabrands.com/Cannabrands/hub/commit/775160457e1e708f929afa7f5beedd6cfccfe603)), closes [#6](https://code.cannabrands.com/Cannabrands/hub/issues/6)
* resolve ambiguous column references in analytics queries ([c960754](https://code.cannabrands.com/Cannabrands/hub/commit/c96075473c99d072b6c971bcf26c3261f8cb3f17))
* resolve double-encoding issue with vendor names containing apostrophes ([6b37783](https://code.cannabrands.com/Cannabrands/hub/commit/6b377837f77e4e6a3a4a3cee9de962c2a8fbdca3))
* resolve JavaScript errors in analytics charts ([608c14a](https://code.cannabrands.com/Cannabrands/hub/commit/608c14a4ffa9206bee4b2908508153a23a2f5dbb))
* restructure BrandResource to correct namespace and add navigation ([a6fe91a](https://code.cannabrands.com/Cannabrands/hub/commit/a6fe91a79ec10ca7fb15c70fe138e1915f27f2b1))
* simplify notification widget by removing table actions ([059e220](https://code.cannabrands.com/Cannabrands/hub/commit/059e22085fee134a81e5279882493c299f8c534a))
* target active state for Choices.js dropdown background ([712ce5b](https://code.cannabrands.com/Cannabrands/hub/commit/712ce5b25f35c4a7e8be891639ec914338c9a302))
* update app layout theme and simplify DaisyUI theme config ([4c38493](https://code.cannabrands.com/Cannabrands/hub/commit/4c384938648a262aab3045e54239a775c2f45a6f))
* update correct field when applying picked_qty changes ([d6d4ef1](https://code.cannabrands.com/Cannabrands/hub/commit/d6d4ef181c286d4f8d545c15c67cce968747aba2))
* update notification view to use correct layout and styling ([dc5627e](https://code.cannabrands.com/Cannabrands/hub/commit/dc5627edc6ccf9cc31db2f8fbc90faae39fdb913))
* update tests for new user model structure and disable Vite in testing ([c165bf9](https://code.cannabrands.com/Cannabrands/hub/commit/c165bf91f5f11615d3a458420ca791fcea59d125))
* update to anime.js v4 API syntax ([cd80da1](https://code.cannabrands.com/Cannabrands/hub/commit/cd80da1cbd51ab95f45a33215c2e92cefe092f5b))
* update UserApprovalService and phpunit config for new schema ([a617272](https://code.cannabrands.com/Cannabrands/hub/commit/a61727253109ae7dd57563d5ff4efb30aee64cba))
* upgrade CI to PHP 8.3 to match composer.lock requirements ([8bddf54](https://code.cannabrands.com/Cannabrands/hub/commit/8bddf543041bce0877e19559fef0a389dc4a4b89))
* use [@apply](https://code.cannabrands.com/apply) with DaisyUI utilities for Choices.js theming ([7aeb56d](https://code.cannabrands.com/Cannabrands/hub/commit/7aeb56d90e472f1f99fa1b9da542b5f1f4befb56))
* use company as location (multi-location not yet implemented) ([6ddfab4](https://code.cannabrands.com/Cannabrands/hub/commit/6ddfab4bfa4fd03d315aa74139489956e61db475))
* use computed primary color for chart gradient ([dd46a92](https://code.cannabrands.com/Cannabrands/hub/commit/dd46a92a3e96751688d51135d75f50f080aa3c4d)), closes [#3b82f6](https://code.cannabrands.com/Cannabrands/hub/issues/3b82f6)
* use correct anime.js v4 two-argument API ([edd488d](https://code.cannabrands.com/Cannabrands/hub/commit/edd488d1aae658b2f25be852eedff0eb6620a93c))
* use DaisyUI CSS variables with !important for Choices.js ([9a42e25](https://code.cannabrands.com/Cannabrands/hub/commit/9a42e2524caaef6de9bdc4b8012661ee5c6cbfa4))
* use dropdown-top for table action menus (DaisyUI pattern) ([9a7ad53](https://code.cannabrands.com/Cannabrands/hub/commit/9a7ad531c86eaf56fb94282716ae30e30e8f4736))
* use hardcoded theme colors instead of CSS variables ([57328fa](https://code.cannabrands.com/Cannabrands/hub/commit/57328fa4e3d67e4b1059b141c3b39ef78a58e652)), closes [#ffffff](https://code.cannabrands.com/Cannabrands/hub/issues/ffffff) [#dcdee0](https://code.cannabrands.com/Cannabrands/hub/issues/dcdee0) [#1e2328](https://code.cannabrands.com/Cannabrands/hub/issues/1e2328) [#181c20](https://code.cannabrands.com/Cannabrands/hub/issues/181c20) [#2c3034](https://code.cannabrands.com/Cannabrands/hub/issues/2c3034) [#f0f4f8](https://code.cannabrands.com/Cannabrands/hub/issues/f0f4f8)
* use HtmlString for dotted underline tooltip on primary toggles ([b85e0a2](https://code.cannabrands.com/Cannabrands/hub/commit/b85e0a2a0141d30edc10d6060463cb2cbb757219))
* use inline-flex for locations header to prevent icon floating ([e0e4094](https://code.cannabrands.com/Cannabrands/hub/commit/e0e4094dcfc97c3da62e2b30afa6139ecf4a1481))
* use line_total column instead of non-existent subtotal in analytics ([0d5904c](https://code.cannabrands.com/Cannabrands/hub/commit/0d5904c816ac742bf9b48dbbf1c63f1927685a95))
* use map syntax for environment variables in Woodpecker CI ([53d6f44](https://code.cannabrands.com/Cannabrands/hub/commit/53d6f443c256c720864298905d4c81952324209f))
* use named export for anime.js v4 ([d6cf85c](https://code.cannabrands.com/Cannabrands/hub/commit/d6cf85c0532e435acd77415cea6a105217c3a86a))
* use server-side Blade conditionals for empty data handling ([f31289b](https://code.cannabrands.com/Cannabrands/hub/commit/f31289be74e10395ec08214c4ed2bca15618ccd7))
* use Storage::url() for product images in invoice show view ([0a0dbd1](https://code.cannabrands.com/Cannabrands/hub/commit/0a0dbd16415b371badab3f5ea38eb4f242867bb3))
* use Storage::url() for product images in picking ticket view ([2a3e4f9](https://code.cannabrands.com/Cannabrands/hub/commit/2a3e4f988b458ab5f373458485c76fcab3c44c50))
* use theme-aware colors for page headers and icons ([e68df40](https://code.cannabrands.com/Cannabrands/hub/commit/e68df403e93ac27db1a1a0292e977493a4f814f9))
* use theme-aware colors in invoices stats cards and table ([102a2ed](https://code.cannabrands.com/Cannabrands/hub/commit/102a2ed927f63ce9ffbc41d805ef27113cf2560b))
* add background color to Choices.js dropdown items ([fcf3689](https://code.cannabrands.app/Cannabrands/hub/commit/fcf36893d9750b33f087f750623f7a34468a5a15))
* add background to nested choices list container ([36a66d6](https://code.cannabrands.app/Cannabrands/hub/commit/36a66d65e2124d830cb40011c5116e28b56b463b))
* add high-specificity selectors for dropdown background ([c7de8d5](https://code.cannabrands.app/Cannabrands/hub/commit/c7de8d5bbe002b8c65bcfe51d279c1fc16e6476a))
* add libpq-dev for PostgreSQL extension and show build output ([e800eab](https://code.cannabrands.app/Cannabrands/hub/commit/e800eab40ea75d6057db837dbca8c26a031001a3))
* add missing Action import in LatestNotifications widget ([3b12568](https://code.cannabrands.app/Cannabrands/hub/commit/3b1256801805e608d7475fe5bbf0efd317663852))
* add missing compliance document methods to Business model ([0c3a935](https://code.cannabrands.app/Cannabrands/hub/commit/0c3a9350d9b74ad18e18f3125c787bf3f521ee55))
* add null user checks to OrderNotificationService ([a81c9a0](https://code.cannabrands.app/Cannabrands/hub/commit/a81c9a09ff71f0fb126220d18003bcff77e7e2fb))
* add overflow-visible to parent containers for dropdown menus ([dd5b7d2](https://code.cannabrands.app/Cannabrands/hub/commit/dd5b7d2b97679ea93f99df9e629e910ba6a09163))
* add z-index and opacity to dropdown to prevent see-through ([1991941](https://code.cannabrands.app/Cannabrands/hub/commit/1991941c4d8fc1a065188352cca0de3cdef08f1f))
* apply conditional dropdown-top to invoices table last row ([8e41203](https://code.cannabrands.app/Cannabrands/hub/commit/8e412031ea84fa350b221f90290de90ed6108fb4))
* broken product images on order show page ([7af3403](https://code.cannabrands.app/Cannabrands/hub/commit/7af34031872e873389a61e9889fc8959ebe0d2df))
* center empty state icon above 'No Locations Yet' text ([5041fec](https://code.cannabrands.app/Cannabrands/hub/commit/5041fece20f7523dd30414950dedd1bceeb28c20))
* change notification actions to redirect instead of returning JSON ([16621ce](https://code.cannabrands.app/Cannabrands/hub/commit/16621ce01c51436a3cdf2244623b559477bc1fac))
* change notification ID type from int to string for UUID support ([a53ce10](https://code.cannabrands.app/Cannabrands/hub/commit/a53ce10864bcf43798a9b10207684f4622c6e5d0))
* connect Analytics navigation link to actual route ([592a05d](https://code.cannabrands.app/Cannabrands/hub/commit/592a05d927715bcdc6acb72173a5165857ee73b5))
* convert business license type to dropdown in Filament admin ([95764a1](https://code.cannabrands.app/Cannabrands/hub/commit/95764a1484b459aa345488d6ea025eafd39aa7ba))
* correct anime.js import path for ES modules ([67e1ed2](https://code.cannabrands.app/Cannabrands/hub/commit/67e1ed270838fb8d67f1890f34e24d020be85ec7))
* correct brand colors column name from brand_colors to colors ([ba4bd33](https://code.cannabrands.app/Cannabrands/hub/commit/ba4bd33924d81561e8fdc44428f3e27186d83479))
* correct Business model relationships and remove non-existent fields ([911865e](https://code.cannabrands.app/Cannabrands/hub/commit/911865e48fe47df45b23d4917812bacd4dc165ee))
* correct business relationship reference in buyer invoice view (Bug [#9](https://code.cannabrands.app/Cannabrands/hub/issues/9)) ([0f3939c](https://code.cannabrands.app/Cannabrands/hub/commit/0f3939c76a04b74195727a2b7ebf34bf1216d1df))
* correct Company-Location relationship foreign key ([5b1969c](https://code.cannabrands.app/Cannabrands/hub/commit/5b1969c0e4d00dcbf615940bb92d9e5642d22be8))
* correct data format for ApexCharts treemaps and Cal-heatmap ([080e1c5](https://code.cannabrands.app/Cannabrands/hub/commit/080e1c5859bf1b49e01a80a0ff738bb41f3fe3b6))
* correct database column names and currency handling in analytics ([b0c7b2d](https://code.cannabrands.app/Cannabrands/hub/commit/b0c7b2d5559199574a559ef0046f3bed9212bfb8))
* correct database references from MySQL to PostgreSQL in README ([3a4fe0a](https://code.cannabrands.app/Cannabrands/hub/commit/3a4fe0ae5fe4c6f518a499e75427e43881b75100))
* correct invoice-brand relationship path (Bug [#9](https://code.cannabrands.app/Cannabrands/hub/issues/9)) ([b1dcb36](https://code.cannabrands.app/Cannabrands/hub/commit/b1dcb363cda527dca0284e61ae801b969e68405f))
* correct picking ticket complete route name (Bug [#8](https://code.cannabrands.app/Cannabrands/hub/issues/8)) ([f0596b2](https://code.cannabrands.app/Cannabrands/hub/commit/f0596b2a839c0ca8be138c0189515d29cf8643d4))
* correct picking ticket route name in seller order view ([a41cdf1](https://code.cannabrands.app/Cannabrands/hub/commit/a41cdf17586f1d65977744d7c7286906279c7ac9)), closes [#7](https://code.cannabrands.app/Cannabrands/hub/issues/7)
* correct Section import for Filament 4 Schema API ([26583b1](https://code.cannabrands.app/Cannabrands/hub/commit/26583b13ddd67a692970934aad882c9ec56251a6))
* correct user_type default and invoice business_id bug ([559d705](https://code.cannabrands.app/Cannabrands/hub/commit/559d7058507480b855f4a38d3b9dcadfdfac6254))
* force Choices.js dropdown to stay closed on page load ([9ddc883](https://code.cannabrands.app/Cannabrands/hub/commit/9ddc88343e24b8d4b7fa42df9dd0eacff2c7d4cd))
* handle null user in invoice show view for manual invoices ([ad2d7f8](https://code.cannabrands.app/Cannabrands/hub/commit/ad2d7f8fda1d27683158e1dd4edf0c01dbdedb5a))
* handle null user in picking ticket view ([92cbc21](https://code.cannabrands.app/Cannabrands/hub/commit/92cbc219eb2dd9c75a70976109ed74f385f8a80c))
* handle null user in seller invoices index view ([7e1c6a0](https://code.cannabrands.app/Cannabrands/hub/commit/7e1c6a0743a413b76b09ad9164134f13a0967d26))
* handle null user in seller order show view for manual invoices ([a772502](https://code.cannabrands.app/Cannabrands/hub/commit/a7725025f987fb666f927bcfe4afbc56188ef3ad))
* improve dark mode text readability with white text ([9264b8b](https://code.cannabrands.app/Cannabrands/hub/commit/9264b8bf1844ab2379067e9b3a6c17b90cc4ddea)), closes [#e3f1eb](https://code.cannabrands.app/Cannabrands/hub/issues/e3f1eb) [#ffffff](https://code.cannabrands.app/Cannabrands/hub/issues/ffffff)
* improve UX when no contacts exist for invoice customer ([5c7d9e6](https://code.cannabrands.app/Cannabrands/hub/commit/5c7d9e6c18e9d82f08f3f789200d7ec4ac04cf67))
* install PHP intl extension and dependencies for Filament ([0ef61eb](https://code.cannabrands.app/Cannabrands/hub/commit/0ef61eb4d10dc9af2c7228cbb8d7a8c216d0fe72))
* make Choices.js vendor dropdown compatible with DaisyUI theme ([a032d98](https://code.cannabrands.app/Cannabrands/hub/commit/a032d98ab5e5d55614a0515deadca00663f6ca95))
* make invoice creation page header theme-aware for dark mode ([5940ed1](https://code.cannabrands.app/Cannabrands/hub/commit/5940ed189f4be97b796395817c1d64338a53264f))
* make locations page header theme-aware and add icon ([91fcf1e](https://code.cannabrands.app/Cannabrands/hub/commit/91fcf1e401a96d1f1fea6e309ddd38d441df5818))
* make vendor dropdown reactive to dark mode toggle ([4b62fab](https://code.cannabrands.app/Cannabrands/hub/commit/4b62fab96f2589fa082a15be8fd4650d4dbb7fc1))
* move postgres to services section and add Composer caching ([bef77df](https://code.cannabrands.app/Cannabrands/hub/commit/bef77df1f6a0d35f3ed170b37f41d3c022106b68))
* only show 'awaiting buyer approval' after invoice is sent ([ea6d2ef](https://code.cannabrands.app/Cannabrands/hub/commit/ea6d2ef2d90feabead3bae5ad06a52198180b3dd))
* prevent Choices.js dropdown auto-opening and clean up single-select display ([36b7e5e](https://code.cannabrands.app/Cannabrands/hub/commit/36b7e5e3d63c80645a732123b4b2a696224e89b8))
* prevent dropdown menu clipping in table rows ([b831dd4](https://code.cannabrands.app/Cannabrands/hub/commit/b831dd4eb5a263ae8aa33132485fd4ced50814da))
* remove [@apply](https://code.cannabrands.app/apply) directives causing invalid CSS variables ([6409436](https://code.cannabrands.app/Cannabrands/hub/commit/6409436f339c8fd329d73dccf1ed2775bcbad5ad))
* remove Alpine x-for from vendor select to fix scope error ([5dc5379](https://code.cannabrands.app/Cannabrands/hub/commit/5dc5379f7023f95211116f723bc256883bc1fcdb))
* remove Alpine.js code and restore working toggle implementation ([2a8f072](https://code.cannabrands.app/Cannabrands/hub/commit/2a8f07200f3de3eea61fe84f712ea0c01bdc2760))
* remove Alpine.js code causing text rendering issues ([4e1706d](https://code.cannabrands.app/Cannabrands/hub/commit/4e1706dae032dc1acb17845494d3a2a8c098145b))
* remove Canna brand - now 12 brands total ([d228dd1](https://code.cannabrands.app/Cannabrands/hub/commit/d228dd1ca75a995e6fb7b8f4e807e9db34a74c45))
* remove Cannabrands as brand - it's a company only ([59db949](https://code.cannabrands.app/Cannabrands/hub/commit/59db949ca0210f518b752c745841183b8c8321aa))
* remove HtmlString label causing text overflow on toggles ([eed5e57](https://code.cannabrands.app/Cannabrands/hub/commit/eed5e570b930c3e9d4d91075b7608d794ea4a7b1))
* remove images column conflict with images() relationship ([79dbb2b](https://code.cannabrands.app/Cannabrands/hub/commit/79dbb2b370fae2ce2eade352da57ab77e487fcf9)), closes [#5](https://code.cannabrands.app/Cannabrands/hub/issues/5)
* remove inappropriate email availability checking from login form ([56b9a32](https://code.cannabrands.app/Cannabrands/hub/commit/56b9a32d1e5e8f3b0bb00b2133e44344e0d7d392))
* remove invalid Tab::description() method call ([fac1f45](https://code.cannabrands.app/Cannabrands/hub/commit/fac1f459e478ca922d524b04ba79292fce98e7ad))
* remove non-existent locations relationship from change fulfillment modal ([8174aad](https://code.cannabrands.app/Cannabrands/hub/commit/8174aadd273360a9e62a83d58070db2b320fa9f9))
* replace $order->company with $order->business in buyer order view ([7751604](https://code.cannabrands.app/Cannabrands/hub/commit/775160457e1e708f929afa7f5beedd6cfccfe603)), closes [#6](https://code.cannabrands.app/Cannabrands/hub/issues/6)
* resolve ambiguous column references in analytics queries ([c960754](https://code.cannabrands.app/Cannabrands/hub/commit/c96075473c99d072b6c971bcf26c3261f8cb3f17))
* resolve double-encoding issue with vendor names containing apostrophes ([6b37783](https://code.cannabrands.app/Cannabrands/hub/commit/6b377837f77e4e6a3a4a3cee9de962c2a8fbdca3))
* resolve JavaScript errors in analytics charts ([608c14a](https://code.cannabrands.app/Cannabrands/hub/commit/608c14a4ffa9206bee4b2908508153a23a2f5dbb))
* restructure BrandResource to correct namespace and add navigation ([a6fe91a](https://code.cannabrands.app/Cannabrands/hub/commit/a6fe91a79ec10ca7fb15c70fe138e1915f27f2b1))
* simplify notification widget by removing table actions ([059e220](https://code.cannabrands.app/Cannabrands/hub/commit/059e22085fee134a81e5279882493c299f8c534a))
* target active state for Choices.js dropdown background ([712ce5b](https://code.cannabrands.app/Cannabrands/hub/commit/712ce5b25f35c4a7e8be891639ec914338c9a302))
* update app layout theme and simplify DaisyUI theme config ([4c38493](https://code.cannabrands.app/Cannabrands/hub/commit/4c384938648a262aab3045e54239a775c2f45a6f))
* update correct field when applying picked_qty changes ([d6d4ef1](https://code.cannabrands.app/Cannabrands/hub/commit/d6d4ef181c286d4f8d545c15c67cce968747aba2))
* update notification view to use correct layout and styling ([dc5627e](https://code.cannabrands.app/Cannabrands/hub/commit/dc5627edc6ccf9cc31db2f8fbc90faae39fdb913))
* update tests for new user model structure and disable Vite in testing ([c165bf9](https://code.cannabrands.app/Cannabrands/hub/commit/c165bf91f5f11615d3a458420ca791fcea59d125))
* update to anime.js v4 API syntax ([cd80da1](https://code.cannabrands.app/Cannabrands/hub/commit/cd80da1cbd51ab95f45a33215c2e92cefe092f5b))
* update UserApprovalService and phpunit config for new schema ([a617272](https://code.cannabrands.app/Cannabrands/hub/commit/a61727253109ae7dd57563d5ff4efb30aee64cba))
* upgrade CI to PHP 8.3 to match composer.lock requirements ([8bddf54](https://code.cannabrands.app/Cannabrands/hub/commit/8bddf543041bce0877e19559fef0a389dc4a4b89))
* use [@apply](https://code.cannabrands.app/apply) with DaisyUI utilities for Choices.js theming ([7aeb56d](https://code.cannabrands.app/Cannabrands/hub/commit/7aeb56d90e472f1f99fa1b9da542b5f1f4befb56))
* use company as location (multi-location not yet implemented) ([6ddfab4](https://code.cannabrands.app/Cannabrands/hub/commit/6ddfab4bfa4fd03d315aa74139489956e61db475))
* use computed primary color for chart gradient ([dd46a92](https://code.cannabrands.app/Cannabrands/hub/commit/dd46a92a3e96751688d51135d75f50f080aa3c4d)), closes [#3b82f6](https://code.cannabrands.app/Cannabrands/hub/issues/3b82f6)
* use correct anime.js v4 two-argument API ([edd488d](https://code.cannabrands.app/Cannabrands/hub/commit/edd488d1aae658b2f25be852eedff0eb6620a93c))
* use DaisyUI CSS variables with !important for Choices.js ([9a42e25](https://code.cannabrands.app/Cannabrands/hub/commit/9a42e2524caaef6de9bdc4b8012661ee5c6cbfa4))
* use dropdown-top for table action menus (DaisyUI pattern) ([9a7ad53](https://code.cannabrands.app/Cannabrands/hub/commit/9a7ad531c86eaf56fb94282716ae30e30e8f4736))
* use hardcoded theme colors instead of CSS variables ([57328fa](https://code.cannabrands.app/Cannabrands/hub/commit/57328fa4e3d67e4b1059b141c3b39ef78a58e652)), closes [#ffffff](https://code.cannabrands.app/Cannabrands/hub/issues/ffffff) [#dcdee0](https://code.cannabrands.app/Cannabrands/hub/issues/dcdee0) [#1e2328](https://code.cannabrands.app/Cannabrands/hub/issues/1e2328) [#181c20](https://code.cannabrands.app/Cannabrands/hub/issues/181c20) [#2c3034](https://code.cannabrands.app/Cannabrands/hub/issues/2c3034) [#f0f4f8](https://code.cannabrands.app/Cannabrands/hub/issues/f0f4f8)
* use HtmlString for dotted underline tooltip on primary toggles ([b85e0a2](https://code.cannabrands.app/Cannabrands/hub/commit/b85e0a2a0141d30edc10d6060463cb2cbb757219))
* use inline-flex for locations header to prevent icon floating ([e0e4094](https://code.cannabrands.app/Cannabrands/hub/commit/e0e4094dcfc97c3da62e2b30afa6139ecf4a1481))
* use line_total column instead of non-existent subtotal in analytics ([0d5904c](https://code.cannabrands.app/Cannabrands/hub/commit/0d5904c816ac742bf9b48dbbf1c63f1927685a95))
* use map syntax for environment variables in Woodpecker CI ([53d6f44](https://code.cannabrands.app/Cannabrands/hub/commit/53d6f443c256c720864298905d4c81952324209f))
* use named export for anime.js v4 ([d6cf85c](https://code.cannabrands.app/Cannabrands/hub/commit/d6cf85c0532e435acd77415cea6a105217c3a86a))
* use server-side Blade conditionals for empty data handling ([f31289b](https://code.cannabrands.app/Cannabrands/hub/commit/f31289be74e10395ec08214c4ed2bca15618ccd7))
* use Storage::url() for product images in invoice show view ([0a0dbd1](https://code.cannabrands.app/Cannabrands/hub/commit/0a0dbd16415b371badab3f5ea38eb4f242867bb3))
* use Storage::url() for product images in picking ticket view ([2a3e4f9](https://code.cannabrands.app/Cannabrands/hub/commit/2a3e4f988b458ab5f373458485c76fcab3c44c50))
* use theme-aware colors for page headers and icons ([e68df40](https://code.cannabrands.app/Cannabrands/hub/commit/e68df403e93ac27db1a1a0292e977493a4f814f9))
* use theme-aware colors in invoices stats cards and table ([102a2ed](https://code.cannabrands.app/Cannabrands/hub/commit/102a2ed927f63ce9ffbc41d805ef27113cf2560b))
### Features
* Add AdminPanelProvider and dashboard_url helper function ([8628e44](https://code.cannabrands.com/Cannabrands/hub/commit/8628e447ce4f2eaaff5eed26cd973a7549038108))
* add ApexCharts and anime.js for dashboard enhancements ([7fca649](https://code.cannabrands.com/Cannabrands/hub/commit/7fca649b37827f2d860e1b692438f2e2e1057e66))
* add Arizona cannabis compliance info to invoice PDF ([25cc2e7](https://code.cannabrands.com/Cannabrands/hub/commit/25cc2e70f6d0cc52fa9e2aeb0ccf59d2651d4f03))
* add batch selection UI to marketplace and cart ([1ccef51](https://code.cannabrands.com/Cannabrands/hub/commit/1ccef51cbb98e94248b2ab01f5818416e52380f0))
* add batch tracking and MSRP support to invoicing ([f2cadb2](https://code.cannabrands.com/Cannabrands/hub/commit/f2cadb2d9336294250006e829a062d15de34a30a))
* add BOM (Bill of Materials) management system ([f1d9684](https://code.cannabrands.com/Cannabrands/hub/commit/f1d96844c3e520365d84abcbe6befb5b0310164c))
* add buyer UI for pickup selection and management ([57c9407](https://code.cannabrands.com/Cannabrands/hub/commit/57c940735aac92737b4b0cc13d08f456f02e6c6a))
* add configurable tax rates for Arizona B2B cannabis compliance ([dd60485](https://code.cannabrands.com/Cannabrands/hub/commit/dd60485206fc8ae10802bb186d25b4f6b24802c2))
* add data migration commands for legacy system import ([cf06bcb](https://code.cannabrands.com/Cannabrands/hub/commit/cf06bcb4d858ea68b95e4ef6a22b9548d1d60df5))
* add database migrations for batch tracking system ([4f53fde](https://code.cannabrands.com/Cannabrands/hub/commit/4f53fde8b2897486fe0163711b79cca55a9bd3bf))
* add Docker image building to CI pipeline + deployment docs ([c658193](https://code.cannabrands.com/Cannabrands/hub/commit/c658193909ed9f54387106a2b8eefa10b375a82d))
* add drag-and-drop reordering to BOM components ([83a9f71](https://code.cannabrands.com/Cannabrands/hub/commit/83a9f713a21355626b360e96431a386d86fc0f07))
* add Filament admin interface for batch management ([e002322](https://code.cannabrands.com/Cannabrands/hub/commit/e0023222ea89ad03d28c7d6c2358325c786133a8))
* add finalize & send functionality for manual invoices ([05a0e74](https://code.cannabrands.com/Cannabrands/hub/commit/05a0e74c66599b12566d415ef3ca023c43b51e1a))
* add gitlab ci starter ([4c3185c](https://code.cannabrands.com/Cannabrands/hub/commit/4c3185ca0a00fdf22f56cc528227bd9ea475bba1))
* add license field validation and storage to location controller ([d77a138](https://code.cannabrands.com/Cannabrands/hub/commit/d77a138985b412655ebff78d984d6cb264def93d))
* add license_type field and clean up Business model ([3b4da67](https://code.cannabrands.com/Cannabrands/hub/commit/3b4da67c6d063c6e1c653d26f4ee3849cc110863))
* add light/dark mode support to ApexCharts ([c2dbf28](https://code.cannabrands.com/Cannabrands/hub/commit/c2dbf284609cf30a73b364cb098f08414fa54255))
* add light/dark theme toggle with sun/moon icons ([5bf5b7c](https://code.cannabrands.com/Cannabrands/hub/commit/5bf5b7ced9d04c9c4c2ee2eadd3f93da2dbab609))
* add location and contact selection to invoice creation form ([e5f2dda](https://code.cannabrands.com/Cannabrands/hub/commit/e5f2dda7f3b44bbd0ce4e64820936d91c3850adb))
* add location license number to invoice PDF ([f6dda38](https://code.cannabrands.com/Cannabrands/hub/commit/f6dda38180431602b58e56113e2cf49ef26f3f3a))
* add Makefiles ([5d3c5d5](https://code.cannabrands.com/Cannabrands/hub/commit/5d3c5d5fe93048c4b5227913af1a0b794fe00531))
* add marketplace controllers and documentation for buyer/seller structure ([eb6db6d](https://code.cannabrands.com/Cannabrands/hub/commit/eb6db6dbdced3adede2fc7391e6d7ec5d94b6685))
* Add multi-step signup onboarding process ([65c6149](https://code.cannabrands.com/Cannabrands/hub/commit/65c6149e3a68215d17c0c773531bb888f9adffac))
* Add new blade component files for sidebar and layout components ([2f88152](https://code.cannabrands.com/Cannabrands/hub/commit/2f8815235c989f180fc78d84ccdf805255db4764))
* Add new blade components for Filament panels ([d6ac894](https://code.cannabrands.com/Cannabrands/hub/commit/d6ac8941b804fbdc4c933c4c802529e53e8bc5b0))
* Add new view files for authentication and dashboard pages ([80f008c](https://code.cannabrands.com/Cannabrands/hub/commit/80f008c2534dff04ceba04cd5150d10b6da3a589))
* add notification infrastructure and policy documentation (Day 15 - Part 1) ([9e4d4de](https://code.cannabrands.com/Cannabrands/hub/commit/9e4d4de07c14950e42be92a02c028ba4f2a93cac))
* Add onboarding confirmation page and file upload endpoint ([b843788](https://code.cannabrands.com/Cannabrands/hub/commit/b8437882d7fafee106a288bfa6a2ce3c79b29a40))
* add optimized BOM UI with advanced features ([b0261b9](https://code.cannabrands.com/Cannabrands/hub/commit/b0261b9d4ab3ec548cb996e149b26b22adc52d9d))
* add PDF export for BOM ([94bc897](https://code.cannabrands.com/Cannabrands/hub/commit/94bc897c86b294093271def8fe911a59e0d7849f))
* add per-brand revenue visualization with color-coded legend ([b00155c](https://code.cannabrands.com/Cannabrands/hub/commit/b00155cf66ad9f7b17f36b3206b23cede13e9f85))
* add picking workflow for manual invoices ([18685fa](https://code.cannabrands.com/Cannabrands/hub/commit/18685fafde9ee9340990f6d1322e6ac9d4a8568b))
* add pickup integration - models and database layer ([a231b4a](https://code.cannabrands.com/Cannabrands/hub/commit/a231b4a4db38d396f9728616e74f3608905ee721))
* add pre-commit hook for automatic code formatting ([8ec361f](https://code.cannabrands.com/Cannabrands/hub/commit/8ec361f49f609fcc7bfb946bf1b478e88864d5f3))
* add product pricing and business enhancements ([0560d5a](https://code.cannabrands.com/Cannabrands/hub/commit/0560d5ac50f67c1bb72feb5a059cf0177fa23715))
* add RelationManagers and actions to ProductResource ([fd1c27e](https://code.cannabrands.com/Cannabrands/hub/commit/fd1c27e066ef3a4a94c293f886b898879f7253ca))
* Add RetailerResource functionality ([831f758](https://code.cannabrands.com/Cannabrands/hub/commit/831f75805d31f5c810e934012853c83e6eaf2f1f))
* add roles, seeders, and new packages ([6c07ce9](https://code.cannabrands.com/Cannabrands/hub/commit/6c07ce92d56d298e8bea7b24e49d1da97342360d))
* Add RolesOverview and UserStats widgets ([5ddb1c8](https://code.cannabrands.com/Cannabrands/hub/commit/5ddb1c840ec5a816b58d01b0a423c75d67881729))
* add seller notification widget to Filament admin dashboard ([04fa99d](https://code.cannabrands.com/Cannabrands/hub/commit/04fa99df240d790adff5e4cc59ae08f9e35ea317))
* add status and version routes ([6d72d88](https://code.cannabrands.com/Cannabrands/hub/commit/6d72d882f4defbb8908e932f08337ee910addedb))
* add stock filter to products page ([392a8ea](https://code.cannabrands.com/Cannabrands/hub/commit/392a8eafe041a93b1d17535a4113a0f64322739b))
* Add SuperAdminSeeder for initial super admin user ([645d26e](https://code.cannabrands.com/Cannabrands/hub/commit/645d26eb25c19849823fe8cb3ea6ba652f957a31))
* add tax configuration UI and location license management ([87d3572](https://code.cannabrands.com/Cannabrands/hub/commit/87d35722ef0933b5f0d7c9cd10f8e9712ed20c79))
* Add user menu and theme switcher components ([43426ed](https://code.cannabrands.com/Cannabrands/hub/commit/43426ed625cd9370c2d4abb0bdd25a0aeb6f8111))
* Add UserResource and related classes and pages ([cdae421](https://code.cannabrands.com/Cannabrands/hub/commit/cdae42170fe8a2699dcdaf5959c60b9fc5bc6d87))
* add utility commands for data cleanup and fixes ([52ecf0b](https://code.cannabrands.com/Cannabrands/hub/commit/52ecf0b3ffc4e831248c8907c1705ebffe4a04f6))
* add vendor management and component images ([b04e2b6](https://code.cannabrands.com/Cannabrands/hub/commit/b04e2b6e6b6153766e70faec46c986fffbf2016b))
* complete shipping manifest system with buyer access and PDF improvements ([031136d](https://code.cannabrands.com/Cannabrands/hub/commit/031136db7de0e398498c9d96ef4d9786012b4d0f))
* comprehensive auth system improvements and user management enhancements ([766d416](https://code.cannabrands.com/Cannabrands/hub/commit/766d416100ad8f3b50f244e7c03c053664a7be2e))
* comprehensive test data setup and account type analysis ([c8987b1](https://code.cannabrands.com/Cannabrands/hub/commit/c8987b1c95b69dd221294518906795e049e1a665))
* convert license type to dropdown with official Arizona types ([246779b](https://code.cannabrands.com/Cannabrands/hub/commit/246779be3bb2ef0e37402492416aa051d018ec0a))
* create fully onboarded test businesses in DevSeeder ([0d0334c](https://code.cannabrands.com/Cannabrands/hub/commit/0d0334c2e97ab200e8fd191d1f04dd229ef5dce7))
* create separate authentication controllers and views for buyers and sellers ([684e947](https://code.cannabrands.com/Cannabrands/hub/commit/684e947496d8ac1daaa2b864ea43cf7bc3755e32))
* create unified registration landing page with user type selection ([87b862c](https://code.cannabrands.com/Cannabrands/hub/commit/87b862c6357171d6524563171996b949c058589b))
* dynamically pull seller business info on invoice PDF ([be0ef68](https://code.cannabrands.com/Cannabrands/hub/commit/be0ef682079eb8decde9d69145a74c5f3e8f56b2))
* enforce single primary contact/user with info icon tooltip ([edb69ed](https://code.cannabrands.com/Cannabrands/hub/commit/edb69ed4c79b8f7d36438654b7e7a98023da47b4))
* fix component images migration to use product_images table ([c1cb84f](https://code.cannabrands.com/Cannabrands/hub/commit/c1cb84fa9d141e517e2a7e0b97558b5d9cf81ccb))
* implement batch tracking models and relationships ([8a753ad](https://code.cannabrands.com/Cannabrands/hub/commit/8a753ad3a624a09e31d6b3c2c1ae6f49f5f5a1fe))
* implement Brand model with 14 actual brands ([49d4f11](https://code.cannabrands.com/Cannabrands/hub/commit/49d4f11102f83ce5d2fdf1add9c95f17d28d2005))
* implement business management features (contacts, locations, users, profiles) ([38ac09e](https://code.cannabrands.com/Cannabrands/hub/commit/38ac09e9e7def2d4d4533b880eee0dfbdea1df4b))
* implement buyer order and invoice management portal (Day 13) ([aaad277](https://code.cannabrands.com/Cannabrands/hub/commit/aaad277a49d80640e7b397d3df22adeda2a8ff7d))
* implement buyer-specific Nexus dashboard with LeafLink-style navigation ([9b72a8f](https://code.cannabrands.com/Cannabrands/hub/commit/9b72a8f3ba97924e94eed806cc014af538110ec2))
* implement buyer/seller routing structure with dual registration options ([5393969](https://code.cannabrands.com/Cannabrands/hub/commit/53939692c01669724372028f8056d9bfdf0bb92a))
* implement buyer/seller user type separation in database layer ([4e15b3d](https://code.cannabrands.com/Cannabrands/hub/commit/4e15b3d15cc8fd046dd40d0a6de512ec8b878b84))
* implement CalVer versioning system with sidebar display ([197d102](https://code.cannabrands.com/Cannabrands/hub/commit/197d10269004f758692cacb444a0e9698ec7e7f1))
* implement Choices.js for vendor selection dropdown ([3c25bb4](https://code.cannabrands.com/Cannabrands/hub/commit/3c25bb49229767845456eb38ef45c41a015583d3))
* implement CompanyResource with approval workflow ([08d3271](https://code.cannabrands.com/Cannabrands/hub/commit/08d32713c37b35535ce7d69021ba0bdaa41c472a))
* implement complete business/company migration from old CRM ([2f163db](https://code.cannabrands.com/Cannabrands/hub/commit/2f163db73814ed336c191b075605b94d4c03a41f))
* implement complete Docker containerization for development and production ([7629500](https://code.cannabrands.com/Cannabrands/hub/commit/7629500d2be40ae93ae1bab58c5f9cefc23c9b5d))
* implement complete email and notification system (Day 15 - Part 2) ([3183c8b](https://code.cannabrands.com/Cannabrands/hub/commit/3183c8b9acb017a702d489064e04d650fc1d063e))
* implement complete Nexus dashboard layout with Iconify integration ([9cb9fcc](https://code.cannabrands.com/Cannabrands/hub/commit/9cb9fcca724abb6c64f0ebb42e35d7655e485715))
* implement complete order management system with picking workflow ([f9fa249](https://code.cannabrands.com/Cannabrands/hub/commit/f9fa2499da4e5bc919a8ee2aa668a3512c4322ae))
* implement complete user approval and dashboard workflow ([7f51605](https://code.cannabrands.com/Cannabrands/hub/commit/7f51605e0d674bb2e6296addb32a51c412804ed1))
* implement comprehensive analytics dashboard with 5 visualizations ([fdfe204](https://code.cannabrands.com/Cannabrands/hub/commit/fdfe204f8b981fc2350cca710f2cddc7f244b88d))
* implement comprehensive business setup form with all CRM sections ([e39623d](https://code.cannabrands.com/Cannabrands/hub/commit/e39623dd08ebe14fb6f0b38690df6cd85128ee4f))
* implement comprehensive buyer dashboard with real-time stats (Day 14) ([98284a6](https://code.cannabrands.com/Cannabrands/hub/commit/98284a6035f0f20a70e2a545a7d724010a204823))
* implement comprehensive notification system and ZOD form validation ([138abc1](https://code.cannabrands.com/Cannabrands/hub/commit/138abc144bcfbb62b11fe5cea9d43c281745f11c))
* implement comprehensive Profile and Settings pages ([8eeee6a](https://code.cannabrands.com/Cannabrands/hub/commit/8eeee6a164d49e9ad667d66fc5f6f0035cf1c939))
* implement core Business model and multi-tenancy architecture ([3883719](https://code.cannabrands.com/Cannabrands/hub/commit/388371982c79ef7d019d3a7bb7ea2c9d375deaec))
* implement CRM-inspired theme and remove logos from auth forms ([6bc6e3e](https://code.cannabrands.com/Cannabrands/hub/commit/6bc6e3eef1abe796214a46e2ed262c69c497ac44)), closes [#014342](https://code.cannabrands.com/Cannabrands/hub/issues/014342) [#015b59](https://code.cannabrands.com/Cannabrands/hub/issues/015b59) [#e3f1eb](https://code.cannabrands.com/Cannabrands/hub/issues/e3f1eb) [#172B4D](https://code.cannabrands.com/Cannabrands/hub/issues/172B4D) [#344767](https://code.cannabrands.com/Cannabrands/hub/issues/344767)
* implement DaisyUI 5.x custom theme syntax ([433b712](https://code.cannabrands.com/Cannabrands/hub/commit/433b712c5dd568c5c33fc2f918447aa9ca538a9d))
* implement fleet management system with delivery scheduling ([7781de5](https://code.cannabrands.com/Cannabrands/hub/commit/7781de51053d53e5dc75bc9152277097641324f7))
* implement interactive dashboard with ApexCharts and anime.js ([3cf5c63](https://code.cannabrands.com/Cannabrands/hub/commit/3cf5c635a9508b134e3e87e8e6e3013d505f24b7))
* implement Lab model and StrainSeeder for cannabis compliance ([a26c0fc](https://code.cannabrands.com/Cannabrands/hub/commit/a26c0fc520d0f3d879f0d74883809a1abaf6964f))
* implement manual admin approval workflow for user registration ([ead8916](https://code.cannabrands.com/Cannabrands/hub/commit/ead8916567d72ccd92e6a89a1b199a3265c6640f))
* implement payment term surcharges at checkout ([cf148a8](https://code.cannabrands.com/Cannabrands/hub/commit/cf148a833062eb1c70cf489cee5760ced4e91d5b))
* implement product migration command with dry-run support ([ba2f434](https://code.cannabrands.com/Cannabrands/hub/commit/ba2f434bcb11eb04f15a50f94cf7023f181c65bb))
* implement Product, Strain, and Component models with BOM system ([d4aebad](https://code.cannabrands.com/Cannabrands/hub/commit/d4aebadbc7d009aa28276051f65f503fcb02bbbd))
* implement ProductResource in Filament with cannabis-specific features ([5d7f7d6](https://code.cannabrands.com/Cannabrands/hub/commit/5d7f7d681086a889c004a1ec53112a6ccc78dee8))
* implement seamless primary toggle switching with Livewire ([fe790d2](https://code.cannabrands.com/Cannabrands/hub/commit/fe790d284ebf9868347ccfef5456e7bfe2a5efb8))
* implement seller notification system (Day 16) ([c024cd8](https://code.cannabrands.com/Cannabrands/hub/commit/c024cd86fe5a2ddd413052facc295c4d966efae5))
* implement unified single login for all user types ([c00e869](https://code.cannabrands.com/Cannabrands/hub/commit/c00e869a82492e555ceb8c3308ba57cd435d3ba4))
* implement UUID-based storage paths for multi-tenant isolation ([2b6f2df](https://code.cannabrands.com/Cannabrands/hub/commit/2b6f2dfd379801063b9d2d048c2256d76e66d17e))
* improve manual invoice workflow to skip picking stages ([ca0bda4](https://code.cannabrands.com/Cannabrands/hub/commit/ca0bda4e852add20992f471946bef8d69bb67b06))
* integrate batch selection in marketplace and cart ([af96aba](https://code.cannabrands.com/Cannabrands/hub/commit/af96abaa1cf527e099d2a792a433edb37a73201b))
* migrate business-level contact fields to Contact records ([d97534d](https://code.cannabrands.com/Cannabrands/hub/commit/d97534d4d3aaf00852fdda99d04f2fe80bfd955a))
* migrate to first_name/last_name fields and fix authentication routing ([42a6f13](https://code.cannabrands.com/Cannabrands/hub/commit/42a6f1330c6ed54b667cc450e41efd9924a0bcff))
* **models:** Add retailer_id field to User model and migration ([25d8e71](https://code.cannabrands.com/Cannabrands/hub/commit/25d8e71ef33e103840ffe087420570d94c1cfcfe))
* optimize form field widths and add Alpine.js formatters ([e58c0ab](https://code.cannabrands.com/Cannabrands/hub/commit/e58c0abcf4d52315408367fa1256e8005f330510))
* optimize theme colors for UX and accessibility ([ee8c7b8](https://code.cannabrands.com/Cannabrands/hub/commit/ee8c7b8e6237c8c61d7960089bc2fb7e556f44b8)), closes [#015b59](https://code.cannabrands.com/Cannabrands/hub/issues/015b59) [#e3f1eb](https://code.cannabrands.com/Cannabrands/hub/issues/e3f1eb)
* remove standard /register route, use only /b/register ([f4de7da](https://code.cannabrands.com/Cannabrands/hub/commit/f4de7da08209ae3a2121b9d5817948d466ffddac))
* rename businesses table and model to companies ([cd02403](https://code.cannabrands.com/Cannabrands/hub/commit/cd02403e025c2f88c4e8f3e26e78432ded1c5f6c))
* replace Spatie Laravel PDF with DomPDF for all PDF generation ([e3cb8b2](https://code.cannabrands.com/Cannabrands/hub/commit/e3cb8b2dfe2277f80c3d9ba2d9fc0b8ca336bd86))
* successfully upgrade Filament from v3 to v4 ([cd77339](https://code.cannabrands.com/Cannabrands/hub/commit/cd7733974293a5eb5a1f67b660dee84d9eb1a19f))
* update business registration form to match main register styling ([b4b4324](https://code.cannabrands.com/Cannabrands/hub/commit/b4b432420bf18405746c779f78da4aa4d98b7478))
* update dashboard page title and breadcrumb for multi-tenant future ([0f872b6](https://code.cannabrands.com/Cannabrands/hub/commit/0f872b6c6a2b7a5edfeab8b830030f80f4ead33c))
* update invoice PDF to display contact and location information ([79fc856](https://code.cannabrands.com/Cannabrands/hub/commit/79fc856850b84fe6697a2f7d290127e2187453f2))
* update manifest PDF template for pickup/delivery ([c7e9f95](https://code.cannabrands.com/Cannabrands/hub/commit/c7e9f952bf109de0096e6584dd8de14c7ea3b6fa))
* update seller/admin views for pickup display ([9f98ab3](https://code.cannabrands.com/Cannabrands/hub/commit/9f98ab381b32953836af5bb6cd786d87d70f0af2))
* upgrade Pest testing framework to v4.0 ([24c2eb2](https://code.cannabrands.com/Cannabrands/hub/commit/24c2eb29a8431e49f48f6d9ea6b9973af63f8f29))
* Add AdminPanelProvider and dashboard_url helper function ([8628e44](https://code.cannabrands.app/Cannabrands/hub/commit/8628e447ce4f2eaaff5eed26cd973a7549038108))
* add ApexCharts and anime.js for dashboard enhancements ([7fca649](https://code.cannabrands.app/Cannabrands/hub/commit/7fca649b37827f2d860e1b692438f2e2e1057e66))
* add Arizona cannabis compliance info to invoice PDF ([25cc2e7](https://code.cannabrands.app/Cannabrands/hub/commit/25cc2e70f6d0cc52fa9e2aeb0ccf59d2651d4f03))
* add batch selection UI to marketplace and cart ([1ccef51](https://code.cannabrands.app/Cannabrands/hub/commit/1ccef51cbb98e94248b2ab01f5818416e52380f0))
* add batch tracking and MSRP support to invoicing ([f2cadb2](https://code.cannabrands.app/Cannabrands/hub/commit/f2cadb2d9336294250006e829a062d15de34a30a))
* add BOM (Bill of Materials) management system ([f1d9684](https://code.cannabrands.app/Cannabrands/hub/commit/f1d96844c3e520365d84abcbe6befb5b0310164c))
* add buyer UI for pickup selection and management ([57c9407](https://code.cannabrands.app/Cannabrands/hub/commit/57c940735aac92737b4b0cc13d08f456f02e6c6a))
* add configurable tax rates for Arizona B2B cannabis compliance ([dd60485](https://code.cannabrands.app/Cannabrands/hub/commit/dd60485206fc8ae10802bb186d25b4f6b24802c2))
* add data migration commands for legacy system import ([cf06bcb](https://code.cannabrands.app/Cannabrands/hub/commit/cf06bcb4d858ea68b95e4ef6a22b9548d1d60df5))
* add database migrations for batch tracking system ([4f53fde](https://code.cannabrands.app/Cannabrands/hub/commit/4f53fde8b2897486fe0163711b79cca55a9bd3bf))
* add Docker image building to CI pipeline + deployment docs ([c658193](https://code.cannabrands.app/Cannabrands/hub/commit/c658193909ed9f54387106a2b8eefa10b375a82d))
* add drag-and-drop reordering to BOM components ([83a9f71](https://code.cannabrands.app/Cannabrands/hub/commit/83a9f713a21355626b360e96431a386d86fc0f07))
* add Filament admin interface for batch management ([e002322](https://code.cannabrands.app/Cannabrands/hub/commit/e0023222ea89ad03d28c7d6c2358325c786133a8))
* add finalize & send functionality for manual invoices ([05a0e74](https://code.cannabrands.app/Cannabrands/hub/commit/05a0e74c66599b12566d415ef3ca023c43b51e1a))
* add gitlab ci starter ([4c3185c](https://code.cannabrands.app/Cannabrands/hub/commit/4c3185ca0a00fdf22f56cc528227bd9ea475bba1))
* add license field validation and storage to location controller ([d77a138](https://code.cannabrands.app/Cannabrands/hub/commit/d77a138985b412655ebff78d984d6cb264def93d))
* add license_type field and clean up Business model ([3b4da67](https://code.cannabrands.app/Cannabrands/hub/commit/3b4da67c6d063c6e1c653d26f4ee3849cc110863))
* add light/dark mode support to ApexCharts ([c2dbf28](https://code.cannabrands.app/Cannabrands/hub/commit/c2dbf284609cf30a73b364cb098f08414fa54255))
* add light/dark theme toggle with sun/moon icons ([5bf5b7c](https://code.cannabrands.app/Cannabrands/hub/commit/5bf5b7ced9d04c9c4c2ee2eadd3f93da2dbab609))
* add location and contact selection to invoice creation form ([e5f2dda](https://code.cannabrands.app/Cannabrands/hub/commit/e5f2dda7f3b44bbd0ce4e64820936d91c3850adb))
* add location license number to invoice PDF ([f6dda38](https://code.cannabrands.app/Cannabrands/hub/commit/f6dda38180431602b58e56113e2cf49ef26f3f3a))
* add Makefiles ([5d3c5d5](https://code.cannabrands.app/Cannabrands/hub/commit/5d3c5d5fe93048c4b5227913af1a0b794fe00531))
* add marketplace controllers and documentation for buyer/seller structure ([eb6db6d](https://code.cannabrands.app/Cannabrands/hub/commit/eb6db6dbdced3adede2fc7391e6d7ec5d94b6685))
* Add multi-step signup onboarding process ([65c6149](https://code.cannabrands.app/Cannabrands/hub/commit/65c6149e3a68215d17c0c773531bb888f9adffac))
* Add new blade component files for sidebar and layout components ([2f88152](https://code.cannabrands.app/Cannabrands/hub/commit/2f8815235c989f180fc78d84ccdf805255db4764))
* Add new blade components for Filament panels ([d6ac894](https://code.cannabrands.app/Cannabrands/hub/commit/d6ac8941b804fbdc4c933c4c802529e53e8bc5b0))
* Add new view files for authentication and dashboard pages ([80f008c](https://code.cannabrands.app/Cannabrands/hub/commit/80f008c2534dff04ceba04cd5150d10b6da3a589))
* add notification infrastructure and policy documentation (Day 15 - Part 1) ([9e4d4de](https://code.cannabrands.app/Cannabrands/hub/commit/9e4d4de07c14950e42be92a02c028ba4f2a93cac))
* Add onboarding confirmation page and file upload endpoint ([b843788](https://code.cannabrands.app/Cannabrands/hub/commit/b8437882d7fafee106a288bfa6a2ce3c79b29a40))
* add optimized BOM UI with advanced features ([b0261b9](https://code.cannabrands.app/Cannabrands/hub/commit/b0261b9d4ab3ec548cb996e149b26b22adc52d9d))
* add PDF export for BOM ([94bc897](https://code.cannabrands.app/Cannabrands/hub/commit/94bc897c86b294093271def8fe911a59e0d7849f))
* add per-brand revenue visualization with color-coded legend ([b00155c](https://code.cannabrands.app/Cannabrands/hub/commit/b00155cf66ad9f7b17f36b3206b23cede13e9f85))
* add picking workflow for manual invoices ([18685fa](https://code.cannabrands.app/Cannabrands/hub/commit/18685fafde9ee9340990f6d1322e6ac9d4a8568b))
* add pickup integration - models and database layer ([a231b4a](https://code.cannabrands.app/Cannabrands/hub/commit/a231b4a4db38d396f9728616e74f3608905ee721))
* add pre-commit hook for automatic code formatting ([8ec361f](https://code.cannabrands.app/Cannabrands/hub/commit/8ec361f49f609fcc7bfb946bf1b478e88864d5f3))
* add product pricing and business enhancements ([0560d5a](https://code.cannabrands.app/Cannabrands/hub/commit/0560d5ac50f67c1bb72feb5a059cf0177fa23715))
* add RelationManagers and actions to ProductResource ([fd1c27e](https://code.cannabrands.app/Cannabrands/hub/commit/fd1c27e066ef3a4a94c293f886b898879f7253ca))
* Add RetailerResource functionality ([831f758](https://code.cannabrands.app/Cannabrands/hub/commit/831f75805d31f5c810e934012853c83e6eaf2f1f))
* add roles, seeders, and new packages ([6c07ce9](https://code.cannabrands.app/Cannabrands/hub/commit/6c07ce92d56d298e8bea7b24e49d1da97342360d))
* Add RolesOverview and UserStats widgets ([5ddb1c8](https://code.cannabrands.app/Cannabrands/hub/commit/5ddb1c840ec5a816b58d01b0a423c75d67881729))
* add seller notification widget to Filament admin dashboard ([04fa99d](https://code.cannabrands.app/Cannabrands/hub/commit/04fa99df240d790adff5e4cc59ae08f9e35ea317))
* add status and version routes ([6d72d88](https://code.cannabrands.app/Cannabrands/hub/commit/6d72d882f4defbb8908e932f08337ee910addedb))
* add stock filter to products page ([392a8ea](https://code.cannabrands.app/Cannabrands/hub/commit/392a8eafe041a93b1d17535a4113a0f64322739b))
* Add SuperAdminSeeder for initial super admin user ([645d26e](https://code.cannabrands.app/Cannabrands/hub/commit/645d26eb25c19849823fe8cb3ea6ba652f957a31))
* add tax configuration UI and location license management ([87d3572](https://code.cannabrands.app/Cannabrands/hub/commit/87d35722ef0933b5f0d7c9cd10f8e9712ed20c79))
* Add user menu and theme switcher components ([43426ed](https://code.cannabrands.app/Cannabrands/hub/commit/43426ed625cd9370c2d4abb0bdd25a0aeb6f8111))
* Add UserResource and related classes and pages ([cdae421](https://code.cannabrands.app/Cannabrands/hub/commit/cdae42170fe8a2699dcdaf5959c60b9fc5bc6d87))
* add utility commands for data cleanup and fixes ([52ecf0b](https://code.cannabrands.app/Cannabrands/hub/commit/52ecf0b3ffc4e831248c8907c1705ebffe4a04f6))
* add vendor management and component images ([b04e2b6](https://code.cannabrands.app/Cannabrands/hub/commit/b04e2b6e6b6153766e70faec46c986fffbf2016b))
* complete shipping manifest system with buyer access and PDF improvements ([031136d](https://code.cannabrands.app/Cannabrands/hub/commit/031136db7de0e398498c9d96ef4d9786012b4d0f))
* comprehensive auth system improvements and user management enhancements ([766d416](https://code.cannabrands.app/Cannabrands/hub/commit/766d416100ad8f3b50f244e7c03c053664a7be2e))
* comprehensive test data setup and account type analysis ([c8987b1](https://code.cannabrands.app/Cannabrands/hub/commit/c8987b1c95b69dd221294518906795e049e1a665))
* convert license type to dropdown with official Arizona types ([246779b](https://code.cannabrands.app/Cannabrands/hub/commit/246779be3bb2ef0e37402492416aa051d018ec0a))
* create fully onboarded test businesses in DevSeeder ([0d0334c](https://code.cannabrands.app/Cannabrands/hub/commit/0d0334c2e97ab200e8fd191d1f04dd229ef5dce7))
* create separate authentication controllers and views for buyers and sellers ([684e947](https://code.cannabrands.app/Cannabrands/hub/commit/684e947496d8ac1daaa2b864ea43cf7bc3755e32))
* create unified registration landing page with user type selection ([87b862c](https://code.cannabrands.app/Cannabrands/hub/commit/87b862c6357171d6524563171996b949c058589b))
* dynamically pull seller business info on invoice PDF ([be0ef68](https://code.cannabrands.app/Cannabrands/hub/commit/be0ef682079eb8decde9d69145a74c5f3e8f56b2))
* enforce single primary contact/user with info icon tooltip ([edb69ed](https://code.cannabrands.app/Cannabrands/hub/commit/edb69ed4c79b8f7d36438654b7e7a98023da47b4))
* fix component images migration to use product_images table ([c1cb84f](https://code.cannabrands.app/Cannabrands/hub/commit/c1cb84fa9d141e517e2a7e0b97558b5d9cf81ccb))
* implement batch tracking models and relationships ([8a753ad](https://code.cannabrands.app/Cannabrands/hub/commit/8a753ad3a624a09e31d6b3c2c1ae6f49f5f5a1fe))
* implement Brand model with 14 actual brands ([49d4f11](https://code.cannabrands.app/Cannabrands/hub/commit/49d4f11102f83ce5d2fdf1add9c95f17d28d2005))
* implement business management features (contacts, locations, users, profiles) ([38ac09e](https://code.cannabrands.app/Cannabrands/hub/commit/38ac09e9e7def2d4d4533b880eee0dfbdea1df4b))
* implement buyer order and invoice management portal (Day 13) ([aaad277](https://code.cannabrands.app/Cannabrands/hub/commit/aaad277a49d80640e7b397d3df22adeda2a8ff7d))
* implement buyer-specific Nexus dashboard with LeafLink-style navigation ([9b72a8f](https://code.cannabrands.app/Cannabrands/hub/commit/9b72a8f3ba97924e94eed806cc014af538110ec2))
* implement buyer/seller routing structure with dual registration options ([5393969](https://code.cannabrands.app/Cannabrands/hub/commit/53939692c01669724372028f8056d9bfdf0bb92a))
* implement buyer/seller user type separation in database layer ([4e15b3d](https://code.cannabrands.app/Cannabrands/hub/commit/4e15b3d15cc8fd046dd40d0a6de512ec8b878b84))
* implement CalVer versioning system with sidebar display ([197d102](https://code.cannabrands.app/Cannabrands/hub/commit/197d10269004f758692cacb444a0e9698ec7e7f1))
* implement Choices.js for vendor selection dropdown ([3c25bb4](https://code.cannabrands.app/Cannabrands/hub/commit/3c25bb49229767845456eb38ef45c41a015583d3))
* implement CompanyResource with approval workflow ([08d3271](https://code.cannabrands.app/Cannabrands/hub/commit/08d32713c37b35535ce7d69021ba0bdaa41c472a))
* implement complete business/company migration from old CRM ([2f163db](https://code.cannabrands.app/Cannabrands/hub/commit/2f163db73814ed336c191b075605b94d4c03a41f))
* implement complete Docker containerization for development and production ([7629500](https://code.cannabrands.app/Cannabrands/hub/commit/7629500d2be40ae93ae1bab58c5f9cefc23c9b5d))
* implement complete email and notification system (Day 15 - Part 2) ([3183c8b](https://code.cannabrands.app/Cannabrands/hub/commit/3183c8b9acb017a702d489064e04d650fc1d063e))
* implement complete Nexus dashboard layout with Iconify integration ([9cb9fcc](https://code.cannabrands.app/Cannabrands/hub/commit/9cb9fcca724abb6c64f0ebb42e35d7655e485715))
* implement complete order management system with picking workflow ([f9fa249](https://code.cannabrands.app/Cannabrands/hub/commit/f9fa2499da4e5bc919a8ee2aa668a3512c4322ae))
* implement complete user approval and dashboard workflow ([7f51605](https://code.cannabrands.app/Cannabrands/hub/commit/7f51605e0d674bb2e6296addb32a51c412804ed1))
* implement comprehensive analytics dashboard with 5 visualizations ([fdfe204](https://code.cannabrands.app/Cannabrands/hub/commit/fdfe204f8b981fc2350cca710f2cddc7f244b88d))
* implement comprehensive business setup form with all CRM sections ([e39623d](https://code.cannabrands.app/Cannabrands/hub/commit/e39623dd08ebe14fb6f0b38690df6cd85128ee4f))
* implement comprehensive buyer dashboard with real-time stats (Day 14) ([98284a6](https://code.cannabrands.app/Cannabrands/hub/commit/98284a6035f0f20a70e2a545a7d724010a204823))
* implement comprehensive notification system and ZOD form validation ([138abc1](https://code.cannabrands.app/Cannabrands/hub/commit/138abc144bcfbb62b11fe5cea9d43c281745f11c))
* implement comprehensive Profile and Settings pages ([8eeee6a](https://code.cannabrands.app/Cannabrands/hub/commit/8eeee6a164d49e9ad667d66fc5f6f0035cf1c939))
* implement core Business model and multi-tenancy architecture ([3883719](https://code.cannabrands.app/Cannabrands/hub/commit/388371982c79ef7d019d3a7bb7ea2c9d375deaec))
* implement CRM-inspired theme and remove logos from auth forms ([6bc6e3e](https://code.cannabrands.app/Cannabrands/hub/commit/6bc6e3eef1abe796214a46e2ed262c69c497ac44)), closes [#014342](https://code.cannabrands.app/Cannabrands/hub/issues/014342) [#015b59](https://code.cannabrands.app/Cannabrands/hub/issues/015b59) [#e3f1eb](https://code.cannabrands.app/Cannabrands/hub/issues/e3f1eb) [#172B4D](https://code.cannabrands.app/Cannabrands/hub/issues/172B4D) [#344767](https://code.cannabrands.app/Cannabrands/hub/issues/344767)
* implement DaisyUI 5.x custom theme syntax ([433b712](https://code.cannabrands.app/Cannabrands/hub/commit/433b712c5dd568c5c33fc2f918447aa9ca538a9d))
* implement fleet management system with delivery scheduling ([7781de5](https://code.cannabrands.app/Cannabrands/hub/commit/7781de51053d53e5dc75bc9152277097641324f7))
* implement interactive dashboard with ApexCharts and anime.js ([3cf5c63](https://code.cannabrands.app/Cannabrands/hub/commit/3cf5c635a9508b134e3e87e8e6e3013d505f24b7))
* implement Lab model and StrainSeeder for cannabis compliance ([a26c0fc](https://code.cannabrands.app/Cannabrands/hub/commit/a26c0fc520d0f3d879f0d74883809a1abaf6964f))
* implement manual admin approval workflow for user registration ([ead8916](https://code.cannabrands.app/Cannabrands/hub/commit/ead8916567d72ccd92e6a89a1b199a3265c6640f))
* implement payment term surcharges at checkout ([cf148a8](https://code.cannabrands.app/Cannabrands/hub/commit/cf148a833062eb1c70cf489cee5760ced4e91d5b))
* implement product migration command with dry-run support ([ba2f434](https://code.cannabrands.app/Cannabrands/hub/commit/ba2f434bcb11eb04f15a50f94cf7023f181c65bb))
* implement Product, Strain, and Component models with BOM system ([d4aebad](https://code.cannabrands.app/Cannabrands/hub/commit/d4aebadbc7d009aa28276051f65f503fcb02bbbd))
* implement ProductResource in Filament with cannabis-specific features ([5d7f7d6](https://code.cannabrands.app/Cannabrands/hub/commit/5d7f7d681086a889c004a1ec53112a6ccc78dee8))
* implement seamless primary toggle switching with Livewire ([fe790d2](https://code.cannabrands.app/Cannabrands/hub/commit/fe790d284ebf9868347ccfef5456e7bfe2a5efb8))
* implement seller notification system (Day 16) ([c024cd8](https://code.cannabrands.app/Cannabrands/hub/commit/c024cd86fe5a2ddd413052facc295c4d966efae5))
* implement unified single login for all user types ([c00e869](https://code.cannabrands.app/Cannabrands/hub/commit/c00e869a82492e555ceb8c3308ba57cd435d3ba4))
* implement UUID-based storage paths for multi-tenant isolation ([2b6f2df](https://code.cannabrands.app/Cannabrands/hub/commit/2b6f2dfd379801063b9d2d048c2256d76e66d17e))
* improve manual invoice workflow to skip picking stages ([ca0bda4](https://code.cannabrands.app/Cannabrands/hub/commit/ca0bda4e852add20992f471946bef8d69bb67b06))
* integrate batch selection in marketplace and cart ([af96aba](https://code.cannabrands.app/Cannabrands/hub/commit/af96abaa1cf527e099d2a792a433edb37a73201b))
* migrate business-level contact fields to Contact records ([d97534d](https://code.cannabrands.app/Cannabrands/hub/commit/d97534d4d3aaf00852fdda99d04f2fe80bfd955a))
* migrate to first_name/last_name fields and fix authentication routing ([42a6f13](https://code.cannabrands.app/Cannabrands/hub/commit/42a6f1330c6ed54b667cc450e41efd9924a0bcff))
* **models:** Add retailer_id field to User model and migration ([25d8e71](https://code.cannabrands.app/Cannabrands/hub/commit/25d8e71ef33e103840ffe087420570d94c1cfcfe))
* optimize form field widths and add Alpine.js formatters ([e58c0ab](https://code.cannabrands.app/Cannabrands/hub/commit/e58c0abcf4d52315408367fa1256e8005f330510))
* optimize theme colors for UX and accessibility ([ee8c7b8](https://code.cannabrands.app/Cannabrands/hub/commit/ee8c7b8e6237c8c61d7960089bc2fb7e556f44b8)), closes [#015b59](https://code.cannabrands.app/Cannabrands/hub/issues/015b59) [#e3f1eb](https://code.cannabrands.app/Cannabrands/hub/issues/e3f1eb)
* remove standard /register route, use only /b/register ([f4de7da](https://code.cannabrands.app/Cannabrands/hub/commit/f4de7da08209ae3a2121b9d5817948d466ffddac))
* rename businesses table and model to companies ([cd02403](https://code.cannabrands.app/Cannabrands/hub/commit/cd02403e025c2f88c4e8f3e26e78432ded1c5f6c))
* replace Spatie Laravel PDF with DomPDF for all PDF generation ([e3cb8b2](https://code.cannabrands.app/Cannabrands/hub/commit/e3cb8b2dfe2277f80c3d9ba2d9fc0b8ca336bd86))
* successfully upgrade Filament from v3 to v4 ([cd77339](https://code.cannabrands.app/Cannabrands/hub/commit/cd7733974293a5eb5a1f67b660dee84d9eb1a19f))
* update business registration form to match main register styling ([b4b4324](https://code.cannabrands.app/Cannabrands/hub/commit/b4b432420bf18405746c779f78da4aa4d98b7478))
* update dashboard page title and breadcrumb for multi-tenant future ([0f872b6](https://code.cannabrands.app/Cannabrands/hub/commit/0f872b6c6a2b7a5edfeab8b830030f80f4ead33c))
* update invoice PDF to display contact and location information ([79fc856](https://code.cannabrands.app/Cannabrands/hub/commit/79fc856850b84fe6697a2f7d290127e2187453f2))
* update manifest PDF template for pickup/delivery ([c7e9f95](https://code.cannabrands.app/Cannabrands/hub/commit/c7e9f952bf109de0096e6584dd8de14c7ea3b6fa))
* update seller/admin views for pickup display ([9f98ab3](https://code.cannabrands.app/Cannabrands/hub/commit/9f98ab381b32953836af5bb6cd786d87d70f0af2))
* upgrade Pest testing framework to v4.0 ([24c2eb2](https://code.cannabrands.app/Cannabrands/hub/commit/24c2eb29a8431e49f48f6d9ea6b9973af63f8f29))
### Performance Improvements
* optimize animations for 60fps performance ([2180897](https://code.cannabrands.com/Cannabrands/hub/commit/2180897cb473a70e3cc8bc2212eebbf7cc343424))
* optimize primary toggle switching for instant response ([ccbbd33](https://code.cannabrands.com/Cannabrands/hub/commit/ccbbd33386818a2a7acee300ec9837b04d2e6264))
* optimize animations for 60fps performance ([2180897](https://code.cannabrands.app/Cannabrands/hub/commit/2180897cb473a70e3cc8bc2212eebbf7cc343424))
* optimize primary toggle switching for instant response ([ccbbd33](https://code.cannabrands.app/Cannabrands/hub/commit/ccbbd33386818a2a7acee300ec9837b04d2e6264))
### Reverts
* Revert "refactor: match products filter styling to components page" ([8578e36](https://code.cannabrands.com/Cannabrands/hub/commit/8578e3680da8b340002d1a989e75e70d105b4333))
* display actual database values for component units ([239390b](https://code.cannabrands.com/Cannabrands/hub/commit/239390b13291cbc5efe6f70c29eed5940bd20564))
* remove multi-brand chart implementation ([b51a4a0](https://code.cannabrands.com/Cannabrands/hub/commit/b51a4a002106a3578b21c5b2d123d6d62ae09814))
* restore working primary toggle implementation ([02135f2](https://code.cannabrands.com/Cannabrands/hub/commit/02135f2b039b9192a3f345bacdeacd5e10ecf019))
* Revert "refactor: match products filter styling to components page" ([8578e36](https://code.cannabrands.app/Cannabrands/hub/commit/8578e3680da8b340002d1a989e75e70d105b4333))
* display actual database values for component units ([239390b](https://code.cannabrands.app/Cannabrands/hub/commit/239390b13291cbc5efe6f70c29eed5940bd20564))
* remove multi-brand chart implementation ([b51a4a0](https://code.cannabrands.app/Cannabrands/hub/commit/b51a4a002106a3578b21c5b2d123d6d62ae09814))
* restore working primary toggle implementation ([02135f2](https://code.cannabrands.app/Cannabrands/hub/commit/02135f2b039b9192a3f345bacdeacd5e10ecf019))

148
CLAUDE.md
View File

@@ -1,51 +1,123 @@
# Claude Code Context
## Important Documentation to Review
## 🚨 Critical Mistakes You Make
Before implementing any features, please review the following documentation:
### 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)
### URL Structure and Architecture
- **ALWAYS** review `docs/URL_STRUCTURE.md` before implementing any routing changes
- The application uses a three-tier user system: buyers, sellers, and admins
- URL prefixes: `/b/` (buyers), `/s/` (sellers), `/admin` (super admins)
**Models needing business_id:** Component, Brand, Product, Driver, Vehicle, Contact, Invoice
**Exception:** Orders span buyer + seller businesses - use `whereHas('items.product.brand')`
### User Types and Account Structure
- **Buyers** (Retailers/Dispensaries): Browse marketplace, instant approval
- **Sellers** (Brands/Manufacturers): Manage products, require approval
- **Admins**: Platform management
### 2. Route Prefixes
Check `docs/URL_STRUCTURE.md` BEFORE route changes.
- `/b/*` → Buyers only
- `/s/*` → Sellers only
- `/admin` → Super admins only
### Development Guidelines
1. Maintain Laravel Breeze compatibility for authentication
2. Follow existing code conventions and patterns
3. Always check existing components before creating new ones
4. Use PostgreSQL-compatible migrations (no IF/ELSE logic)
5. Test routes after implementation
6. Create informative git commits with clear messages
### 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
### Testing Credentials
- Buyer: `dispensary@example.com` / `password`
- Seller: `brand@example.com` / `password`
- Admin: `admin@example.com` / `password`
### 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
### Current Architecture Decisions
- Dual registration flow with informative landing page at `/register`
- Separate authentication controllers for buyers and sellers
- Marketplace functionality under `/b/` prefix
- Brand/seller CRM functionality under `/s/` prefix
### 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
## Commands to Run After Changes
- Clear caches: `php artisan cache:clear && php artisan config:clear && php artisan route:clear`
- Run migrations: `php artisan migrate`
- Seed test data: `php artisan db:seed --class=DevSeeder`
### 6. PostgreSQL Migrations
❌ No IF/ELSE logic in migrations (not supported)
✅ Use Laravel Schema builder or conditional PHP code
## Server Requirements
### 7. 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
### PDF Generation (DomPDF)
This application uses DomPDF (`barryvdh/laravel-dompdf`) for generating cannabis shipping manifests and invoices.
**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
**No special server requirements needed** - DomPDF is pure PHP and works out of the box on all platforms (Linux, macOS, Windows, ARM64, x86_64).
**Exception:** Only use inline styles for truly dynamic values from database (e.g., user-uploaded brand colors)
**Configuration:**
- Package: `barryvdh/laravel-dompdf`
- Already installed via Composer
- No additional system dependencies required
---
## 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.
---
## Testing & Git
**Before commit:**
```bash
php artisan test --parallel # REQUIRED
./vendor/bin/pint # REQUIRED
```
**Credentials:** `{buyer,seller,admin}@example.com` / `password`
**Branches:** Never commit to `master`/`develop` directly - use feature branches
**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!
```
---
## External Docs (Read When Needed)
- `docs/URL_STRUCTURE.md` - **READ BEFORE** routing changes
- `docs/DATABASE.md` - **READ BEFORE** migrations
- `docs/DEVELOPMENT.md` - Local setup
- `CONTRIBUTING.md` - Detailed git workflow
---
## 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

View File

@@ -1,891 +0,0 @@
# Claude Code Collaboration Workflow Guide
**Project:** Cannabrands CRM → Filament Migration
**Timeline:** 28 days
**Developer:** Solo developer + Claude Code
**Last Updated:** January 2025
---
## 📋 Overview
This guide provides prompt templates, reference strategies, and workflows for effectively collaborating with Claude Code during the migration from the legacy Laravel 9 + VentureDrake CRM system to the new Laravel 12 + Filament 4 platform.
**Key Principle:** The old codebase (`/cannabrands_crm`) contains **business logic** that must be preserved, but **architectural patterns** that should NOT be replicated.
---
## 🎯 Prompt Templates
### Template 1: Implementing a New Feature
```
I need to implement [FEATURE NAME] from the old system.
**Old System Reference:**
- Location: [file path in /cannabrands_crm]
- Key business rules: [specific rules to preserve]
- Data involved: [models/tables]
**New System Requirements:**
- Filament resource: [Yes/No]
- Public-facing page: [Yes/No]
- Special considerations: [any unique requirements]
Please:
1. Read the old implementation to understand the business logic
2. Implement using Filament 4 best practices
3. Preserve all validation rules and business logic
4. Do NOT copy the CRM-specific patterns
Reference documents:
- FEATURE_IMPLEMENTATION_ROADMAP.md (Day X task)
- FILAMENT_RESOURCES_SPEC.md (if applicable)
```
**Example Usage:**
```
I need to implement the buyer application approval workflow.
**Old System Reference:**
- Location: vendor/venturedrake/laravel-crm/src/Http/Controllers/CompaniesController.php (lines 520-790)
- Key business rules:
- Requires license document upload
- Requires W9 tax form
- Admin approval required before account activation
- Email notification on approval/rejection
- Data involved: companies table, documents, email_verifications
**New System Requirements:**
- Filament resource: Yes (CompanyResource)
- Public-facing page: Yes (buyer registration form)
- Special considerations: Must integrate with existing email verification system
Please:
1. Read the old implementation to understand document validation
2. Implement using Filament 4 actions and notifications
3. Preserve license/W9 validation logic
4. Do NOT copy the CRM Organization model patterns
```
---
### Template 2: Understanding Business Logic
```
I need to understand how [BUSINESS PROCESS] works in the old system.
**Process:** [name of workflow/calculation/rule]
**Why:** [what you're trying to implement that needs this]
Please:
1. Search the old codebase for relevant files
2. Trace the workflow from start to finish
3. Identify all business rules, validations, and side effects
4. Explain the logic in plain English
5. Recommend how to implement in new system
Do NOT implement yet - just analyze and explain.
```
**Example Usage:**
```
I need to understand how orders convert to invoices in the old system.
**Process:** Order-to-Invoice conversion workflow
**Why:** Need to implement this in new OrderResource and InvoiceResource
Please:
1. Search the old codebase for order/invoice conversion logic
2. Trace the workflow from order creation → invoice generation
3. Identify all status transitions and triggers
4. Explain when/why orders become invoices
5. Recommend how to implement in Filament with separate Order/Invoice models
Do NOT implement yet - just analyze and explain.
```
---
### Template 3: Creating a Migration Script
```
I need to migrate [DATA TYPE] from old database to new database.
**Old Table(s):** [table names]
**New Table(s):** [table names]
**Record Count:** [approximate number]
**Special Considerations:** [foreign keys, transformations, etc.]
Reference:
- SCHEMA_TRANSFORMATION.md (Section: [section name])
Please:
1. Create Laravel migration file
2. Write data migration script (using DB facade or raw SQL)
3. Include data transformation logic from SCHEMA_TRANSFORMATION.md
4. Add verification queries to confirm successful migration
5. Include rollback logic
Test with a small subset first (LIMIT 10).
```
**Example Usage:**
```
I need to migrate products from old database to new database.
**Old Table(s):** crm_products, crm_brands, products
**New Table(s):** products, brands
**Record Count:** 883 products
**Special Considerations:**
- 552 products have NULL brand_id (assign to default "Cannabrands" brand)
- Parent-child relationships for product varieties
- BOM flags (isAssembly, isRaw, isSellable)
Reference:
- SCHEMA_TRANSFORMATION.md (Section: Products Migration)
Please:
1. Create Laravel migration file
2. Write data migration script with brand assignment logic
3. Handle NULL brand_id → default to Cannabrands
4. Preserve parent_product_id relationships
5. Include COUNT verification before/after
Test with LIMIT 10 first, then show me results before full migration.
```
---
### Template 4: Debugging Behavior Mismatch
```
The new implementation doesn't match old system behavior.
**Feature:** [what you implemented]
**Expected Behavior:** [what should happen, based on old system]
**Actual Behavior:** [what's happening in new system]
**Old System Reference:** [file/line where expected behavior exists]
Please:
1. Read the old implementation carefully
2. Identify what business logic I missed
3. Show me the discrepancy
4. Fix the new implementation to match
```
**Example Usage:**
```
The new implementation doesn't match old system behavior.
**Feature:** Order acceptance workflow
**Expected Behavior:** When seller creates order, buyer must accept before it can be fulfilled. When buyer creates order, seller must accept.
**Actual Behavior:** All orders can be immediately marked as accepted regardless of who created them.
**Old System Reference:** app/Models/Invoice.php (acceptable() method, lines 45-52)
Please:
1. Read the old Invoice model's acceptable() logic
2. Identify the created_by and status checks I missed
3. Show me what validation rules are missing
4. Update OrderResource to match this business rule
```
---
## 🔍 Reference Strategy
### ✅ WHEN to Read Old Codebase
**1. Business Rules & Validation**
```php
// OLD: vendor/venturedrake/laravel-crm/src/Http/Requests/StoreCompanyRequest.php
'license_number' => 'required|string|max:50|unique:companies',
'license_document' => 'required|file|mimes:pdf,jpg,png|max:5120',
// Extract these rules for new CompanyResource
```
**2. Calculations & Formulas**
```php
// OLD: app/Models/Invoice.php
public function calculateTotal() {
return $this->lines->sum(function($line) {
return $line->quantity * $line->unit_price * (1 - $line->discount/100);
}) + $this->shipping_cost + $this->tax_amount;
}
// Preserve exact calculation logic in new Order model
```
**3. Status Workflows**
```php
// OLD: Check status transitions and conditions
if ($order->status == 'new' && $order->created_by === 'seller') {
// Buyer must accept
} elseif ($order->status == 'accepted') {
// Can be fulfilled
}
// Replicate state machine logic in new system
```
**4. Email Templates & Notifications**
```php
// OLD: resources/views/emails/order/accepted.blade.php
// Copy branding, copy, and structure
// Update to use new Filament notification patterns
```
**5. Complex Queries**
```php
// OLD: Multi-table joins, aggregations, report logic
$products = Product::with(['brand', 'parent'])
->where('is_active', true)
->whereHas('brand', fn($q) => $q->where('public', true))
->get();
// Preserve query structure and business logic
```
---
### ❌ WHAT NOT to Copy
**1. VentureDrake Model Patterns**
```php
// DON'T COPY THIS:
namespace VentureDrake\LaravelCrm\Models;
use VentureDrake\LaravelCrm\Traits\BelongsToTeams;
// Instead: Use clean Laravel models
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
```
**2. CRM-Specific Features**
```php
// DON'T COPY: Leads, Deals, Pipelines, Campaigns
// These are unused CRM bloat - implement only what's needed
```
**3. Vendor Overrides & Hacks**
```php
// DON'T COPY: Direct vendor file modifications
// Use proper Laravel extension patterns instead
```
**4. Inefficient Queries**
```php
// DON'T COPY: N+1 queries or missing eager loading
foreach ($orders as $order) {
echo $order->company->name; // N+1 problem
}
// Instead: Optimize with eager loading
Order::with('company')->get();
```
**5. Inline Business Logic in Controllers**
```php
// DON'T COPY: Fat controllers
public function store(Request $request) {
// 200 lines of business logic...
}
// Instead: Use Services, Actions, or Model methods
public function store(Request $request, OrderService $service) {
$service->createOrder($request->validated());
}
```
---
## 🔄 Development Workflow
### Git Strategy
**Branch Naming:**
```bash
feature/[feature-name] # New features (e.g., feature/shopping-cart)
fix/[bug-description] # Bug fixes (e.g., fix/order-total-calculation)
migrate/[data-type] # Data migrations (e.g., migrate/products)
```
**Commit Message Pattern:**
```
[type]: [concise description]
[Optional detailed explanation]
[Reference to old system if applicable]
Examples:
✅ feat: implement buyer application approval workflow
- Add CompanyResource approval/rejection actions
- Preserve license validation from old CompaniesController.php:520
- Add email notifications on status change
✅ fix: correct order total calculation to match legacy system
- Include tax and shipping in total
- Reference: app/Models/Invoice.php:calculateTotal()
✅ migrate: import 883 products from old database
- Assign 552 unbranded products to default Cannabrands brand
- Preserve parent_product_id relationships
- Verified: All products migrated successfully
```
**Daily Workflow:**
```bash
# Morning: Start day's feature
git checkout -b feature/[todays-feature]
# During: Commit frequently
git add .
git commit -m "feat: [incremental progress]"
# End of Day: Merge if complete and tested
git checkout feature/migration-implementation
git merge feature/[todays-feature] --no-ff
git branch -d feature/[todays-feature]
```
---
### Testing After Each Feature
**1. Filament Resource Testing**
```
After implementing [Resource]Resource:
1. Visual Test:
- Visit /admin/[resources] in browser
- Create new record
- Edit existing record
- Test filters and search
- Test custom actions
2. Validation Test:
- Try submitting empty form (should fail)
- Try invalid data (should show errors)
- Try valid data (should succeed)
3. Relationship Test:
- Test relation managers (if any)
- Verify related records display
- Test creating related records
4. Permission Test (if roles implemented):
- Test as admin (should have full access)
- Test as regular user (should have limited access)
```
**2. Public Page Testing**
```
After implementing public-facing page:
1. Guest Test:
- Access page without login
- Submit form with valid data
- Submit form with invalid data
2. Email Test:
- Check Mailpit (localhost:8025)
- Verify email content and styling
- Test email links
3. Flow Test:
- Complete full user journey (register → verify → login)
- Check database records created
- Verify relationships created
```
**3. Migration Script Testing**
```
After creating migration script:
1. Test Run (Small Subset):
php artisan migrate:fresh --seed
php artisan migrate:legacy:products --limit=10
2. Verification Queries:
SELECT COUNT(*) FROM products;
SELECT * FROM products WHERE brand_id IS NULL; -- Should be 0
SELECT * FROM products WHERE parent_product_id IS NOT NULL; -- Varieties
3. Rollback Test:
php artisan migrate:rollback
-- Verify data removed cleanly
4. Full Migration (After test passes):
php artisan migrate:legacy:products
```
---
## 🐛 Debugging Patterns
### Pattern 1: Behavior Doesn't Match Old System
**Symptom:** "This works, but it's not doing what the old system did"
**Process:**
```
1. Find old implementation:
Prompt: "Search old codebase for [feature] logic in controllers, models, and services"
2. Trace execution:
Prompt: "Read [old file] and explain step-by-step what happens when [action occurs]"
3. Compare implementations:
Prompt: "Here's my new implementation [paste code]. Compare to old system and identify missing business logic"
4. Fix discrepancies:
Prompt: "Update new implementation to include [missing rule] from old system"
```
**Example:**
```
User: "Order totals in new system don't match old invoices"
Claude: Let me search for total calculation in old system
[Reads app/Models/Invoice.php]
Claude: I found the issue. Old system includes:
- Line items (quantity × price × discount)
- Shipping cost
- Tax amount
Your new Order model is missing shipping and tax. Here's the fix:
[Provides corrected calculation method]
```
---
### Pattern 2: Data Migration Produces Incorrect Results
**Symptom:** "Migration ran, but data looks wrong"
**Process:**
```
1. Run diagnostic queries:
SELECT COUNT(*), brand_id FROM products GROUP BY brand_id;
SELECT * FROM products WHERE [expected_field] IS NULL LIMIT 10;
2. Compare to old system:
-- Run same query on old database
SELECT COUNT(*), brand_id FROM crm_products GROUP BY brand_id;
3. Identify transformation error:
Prompt: "My migration produced [X results] but old system has [Y results].
Here's my migration script [paste]. Find the bug."
4. Fix and re-run:
php artisan migrate:rollback
[Fix migration script]
php artisan migrate
```
---
### Pattern 3: Filament Action Not Working
**Symptom:** "Button appears but clicking does nothing / shows error"
**Debugging Steps:**
```
1. Check browser console for JS errors
2. Check Laravel logs: tail -f storage/logs/laravel.log
3. Verify action requirements:
- Is user authorized?
- Are required fields present?
- Is model state correct?
Prompt: "My Filament action [action name] in [Resource] isn't working.
Here's the code [paste]. Here's the error [paste].
Reference old system at [file path] to see how it should work."
```
**Common Fixes:**
```php
// Missing authorization
Tables\Actions\Action::make('approve')
->visible(fn ($record) => $record->status === 'pending') // Add this
// Missing requiresConfirmation
Tables\Actions\Action::make('delete')
->requiresConfirmation() // Add this for destructive actions
// Missing success notification
->action(function ($record) {
$record->approve();
Notification::make()->success()->title('Approved')->send(); // Add this
})
```
---
## 💡 Example Interactions
### Example 1: Day 1 - Company Model Implementation
**User:**
```
Starting Day 1 tasks. I need to:
1. Rename businesses table to companies
2. Add buyer application fields
3. Create CompanyResource
Reference:
- FEATURE_IMPLEMENTATION_ROADMAP.md (Day 1)
- FILAMENT_RESOURCES_SPEC.md (CompanyResource)
- Old system: vendor/venturedrake/laravel-crm/src/Models/Organisation.php
Please implement step 1 (rename migration) first.
```
**Claude:**
```
I'll create a migration to rename the businesses table to companies and add buyer application fields.
[Creates migration file]
[Shows migration code]
This migration:
1. Renames businesses → companies
2. Adds license_number, license_document_path, w9_document_path
3. Adds buyer application fields (status, approved_at, rejected_at, rejection_reason)
4. Preserves all existing data
Run: php artisan migrate
After confirming this works, I'll proceed to step 2 (CompanyResource).
```
**User:**
```
Migration ran successfully. Proceed with CompanyResource.
```
**Claude:**
```
I'll create the CompanyResource with approval workflow.
First, let me read the old buyer application logic:
[Reads vendor/venturedrake/.../CompaniesController.php lines 520-790]
I see the approval workflow requires:
- License document validation
- W9 document validation
- Admin approval action
- Email notification on approval/rejection
Creating CompanyResource with these features...
[Creates resource file]
[Shows code]
Test by:
1. Visit /admin/companies
2. Click on a pending company
3. Click "Approve" action
4. Check email in Mailpit (localhost:8025)
```
---
### Example 2: Understanding Order Workflow
**User:**
```
I need to understand how orders convert to invoices in the old system.
**Process:** Order-to-Invoice conversion workflow
**Why:** Need to implement this in new OrderResource and InvoiceResource
Please analyze and explain - don't implement yet.
```
**Claude:**
```
Let me trace the order-to-invoice workflow in the old system.
[Searches for 'invoice' AND 'order' in old codebase]
[Reads app/Models/Invoice.php]
[Reads vendor/venturedrake/.../InvoiceController.php]
Here's how it works:
**Data Structure:**
- Old system uses SINGLE table: invoices
- Column: type (values: 'order', 'invoice', 'quote', 'draft')
- Column: status (values: 'new', 'accepted', 'create_manifest', 'invoiced', 'delivered')
**Workflow:**
1. Order Created:
- type = 'order'
- status = 'new'
- created_by = 'seller' OR 'buyer'
2. Acceptance:
- If created_by = 'seller' → buyer must accept
- If created_by = 'buyer' → seller must accept
- Status changes: 'new' → 'accepted'
3. Fulfillment Prep:
- Admin marks order for fulfillment
- Status changes: 'accepted' → 'create_manifest'
4. **CONVERSION TRIGGER** (InvoiceController.php:434):
- When status = 'create_manifest'
- Type changes: 'order' → 'invoice'
- Status changes: 'create_manifest' → 'invoiced'
5. Delivery:
- Status changes: 'invoiced' → 'delivered'
- Payment tracking begins
**Recommendation for New System:**
- Use separate Order and Invoice models/tables
- When Order.status = 'fulfilled', create Invoice record
- Copy order data to invoice (preserve order_id foreign key)
- Keep Order record for history (don't delete)
This gives you:
- Cleaner separation of concerns
- Better audit trail
- Easier reporting
Would you like me to implement this workflow now?
```
**User:**
```
Yes, implement this in OrderResource and InvoiceResource.
```
---
### Example 3: Debugging Migration Discrepancy
**User:**
```
I ran the products migration but the counts are wrong.
Expected (from old system): 883 products
Actual (new system): 1435 products
Here's my migration script: [pastes script]
```
**Claude:**
```
Let me investigate. First, let me check if the old system has soft deletes:
[Reads old products table schema]
Found the issue! The old crm_products table has a deleted_at column.
Your migration script is copying ALL records, including soft-deleted ones.
Old system:
- Total records: 1435
- Active (deleted_at IS NULL): 883
- Soft-deleted: 552
Fix:
[Shows updated migration script with WHERE deleted_at IS NULL]
Run this to fix:
php artisan migrate:rollback
php artisan migrate
php artisan migrate:legacy:products
You should now see exactly 883 products.
```
---
## 🎯 Daily Checklist
**Every Morning:**
```
[ ] Review FEATURE_IMPLEMENTATION_ROADMAP.md for today's tasks
[ ] Create feature branch: git checkout -b feature/[todays-feature]
[ ] Identify which old files contain business logic for today's feature
```
**During Implementation:**
```
[ ] Read old implementation first (understand before coding)
[ ] Extract business rules (validations, calculations, workflows)
[ ] Implement in Filament/Laravel best practices
[ ] Do NOT copy CRM-specific patterns
[ ] Commit incrementally (every 1-2 hours)
```
**Before Marking Task Complete:**
```
[ ] Visual test in browser (if applicable)
[ ] Validation test (try to break it)
[ ] Check Laravel logs for errors
[ ] Run relevant test suite (if tests exist)
[ ] Commit final changes with descriptive message
```
**End of Day:**
```
[ ] Merge feature branch if complete and tested
[ ] Update progress in FEATURE_IMPLEMENTATION_ROADMAP.md (add checkmarks)
[ ] Note any blockers or questions for tomorrow
[ ] Push to remote: git push origin feature/migration-implementation
```
---
## 📞 When to Ask for Clarification
**ASK when:**
- Old system has conflicting business rules in different files
- Data migration produces unexpected results
- You're unsure which old implementation is the "source of truth"
- Performance implications of copying old approach (e.g., N+1 queries)
- Security concerns with old implementation
**DON'T ASK when:**
- You can find answer in old codebase (read it first)
- It's covered in migration documents (SCHEMA_TRANSFORMATION.md, etc.)
- It's a standard Laravel/Filament pattern (use best practices)
- Old implementation is clearly wrong (use correct approach in new system)
---
## 🚨 Common Pitfalls & Solutions
### Pitfall 1: Copying CRM Model Structure
```
❌ WRONG:
namespace App\Models;
use VentureDrake\LaravelCrm\Traits\BelongsToTeams;
class Company extends Model {
use BelongsToTeams; // Don't copy CRM traits
}
✅ RIGHT:
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Company extends Model {
// Clean Laravel model
}
```
### Pitfall 2: Assuming Table Names Match
```
❌ WRONG:
Assuming old "organisations" = new "companies" directly
✅ RIGHT:
Check SCHEMA_TRANSFORMATION.md for exact table mappings
Old: companies (CRM Contact companies) + organisations (CRM Orgs)
New: companies (unified, with type field)
```
### Pitfall 3: Missing Business Rules
```
❌ WRONG:
Creating generic CRUD without checking old validations
✅ RIGHT:
Read old Request classes for validation rules:
- StoreCompanyRequest.php
- UpdateProductRequest.php
Extract rules and apply to Filament resource
```
### Pitfall 4: Over-Engineering Too Soon
```
❌ WRONG:
Implementing features that don't exist in old system
"Should I add multi-currency support?"
✅ RIGHT:
Implement only what old system has (feature parity first)
Note ideas for post-launch improvements
```
---
## 📚 Quick Reference
**Old Codebase Locations:**
```
Business Logic: app/Models/*.php
Controllers: app/Http/Controllers/**/*.php
Validations: app/Http/Requests/*.php
CRM Controllers: vendor/venturedrake/.../Controllers/*.php
CRM Models: vendor/venturedrake/.../Models/*.php
Email Templates: resources/views/emails/**/*.blade.php
Routes: routes/*.php
```
**New Codebase Locations:**
```
Models: app/Models/*.php
Filament Resources: app/Filament/Resources/*.php
Services: app/Services/*.php
Migrations: database/migrations/*.php
Seeders: database/seeders/*.php
Public Pages: app/Http/Controllers/**/*.php
Views: resources/views/**/*.blade.php
```
**Key Documents:**
```
Migration Strategy: MIGRATION_MASTER_PLAN.md
Data Mappings: SCHEMA_TRANSFORMATION.md
Day-by-Day Tasks: FEATURE_IMPLEMENTATION_ROADMAP.md
Filament Code: FILAMENT_RESOURCES_SPEC.md
This Guide: CLAUDE_COLLABORATION_WORKFLOW.md
```
**Useful Commands:**
```bash
# Development
php artisan serve
php artisan migrate
php artisan migrate:fresh --seed
# Testing
php artisan tinker
php artisan route:list
tail -f storage/logs/laravel.log
# Email Testing
docker-compose up mailpit -d
# Visit: localhost:8025
# Database
php artisan db:seed
php artisan migrate:rollback
```
---
## ✅ Success Indicators
**You're on the right track when:**
- New feature behavior matches old system exactly
- Migration scripts produce expected record counts
- Filament resources are cleaner than old CRM code
- Business rules are preserved, but implementation is modern
- You can explain why new approach is better than old
**Red flags:**
- Copying vendor file structures
- Implementing unused CRM features
- Can't explain business rule origin
- Migration counts don't match old system
- New feature does something old system didn't
---
**End of Guide**
*This document evolves as you progress through the migration. Update it with new patterns, pitfalls, and solutions as you discover them.*

View File

@@ -44,7 +44,7 @@ Our workflow provides audit trails regulators love:
1. **Clone the repository**
```bash
git clone https://code.cannabrands.com/Cannabrands/hub.git
git clone https://code.cannabrands.app/Cannabrands/hub.git
cd hub
```
@@ -68,21 +68,59 @@ Our workflow provides audit trails regulators love:
---
## Branch Protection & Pull Request Workflow
**IMPORTANT:** The `develop` and `master` branches are **protected** - you cannot push directly to them.
### Standard Workflow:
```bash
# 1. Create a feature branch
git checkout -b feature/my-feature-name
# 2. Make changes and commit
git add .
git commit -m "feat: add new feature"
# 3. Push to your feature branch
git push origin feature/my-feature-name
# 4. Create Pull Request on Gitea
# - Navigate to https://code.cannabrands.app
# - Create PR to merge your branch into develop
# - CI will run automatically
# - Request review from team
# 5. After approval and passing CI
# - Merge PR via Gitea interface
# - Delete feature branch
```
### Branch Naming Conventions:
- `feature/` - New features (e.g., `feature/bulk-import`)
- `fix/` - Bug fixes (e.g., `fix/tax-calculation`)
- `chore/` - Maintenance tasks (e.g., `chore/upgrade-php`)
- `docs/` - Documentation changes (e.g., `docs/update-readme`)
---
## Real-World Team Scenarios
### Scenario 1: Normal Feature Development
**Developer Jon adds bulk import feature**
```bash
$ git checkout -b feature/bulk-import # Create feature branch
$ vim app/Orders.php # Make changes
$ git add .
$ git commit -m "feat(orders): add bulk import"
🎨 Pre-commit: Pint formats code (1s) ✅
$ git push origin master
$ git push origin feature/bulk-import
🧪 Pre-push: Tests run (30s) ✅
✅ All tests passed! Pushing...
🚀 CI: Full verification (5min) ✅
🚀 Create PR → merge to develop → CI verifies (5min) ✅
```
**Time cost: 31 seconds** (vs 5+ minutes if tests failed in CI)
@@ -122,14 +160,15 @@ $ git push --no-verify # Skip tests intentionally
**Developer Emma fixes production bug**
```bash
$ vim app/Invoice.php # Critical bug fix
$ git checkout -b fix/tax-calculation # Create hotfix branch
$ vim app/Invoice.php # Critical bug fix
$ git commit -m "fix(invoices): correct tax calculation"
🎨 Pre-commit: Formats ✅
$ git push origin master
$ git push origin fix/tax-calculation
🧪 Pre-push: Tests run (30s) ✅
🚀 CI: Passes (5min)
📦 Deploy: Safe to release ✅
🚀 Create PR → fast-track review → merge to develop
📦 CI: Passes (5min) → Safe to release ✅
```
**Safety: Tests caught regression** before it reached production
@@ -138,7 +177,8 @@ $ git push origin master
**Developer Alex updates dependencies**
```bash
$ vim Dockerfile # Update PHP version
$ git checkout -b chore/php-8.3-upgrade # Create branch
$ vim Dockerfile # Update PHP version
# Test locally FIRST (best practice)
$ docker build -t cannabrands:test .
@@ -146,8 +186,8 @@ $ docker build -t cannabrands:test .
# Then push
$ git commit -m "chore: upgrade PHP to 8.3"
$ git push origin master
🚀 CI: Rebuilds (8min) ✅
$ git push origin chore/php-8.3-upgrade
🚀 Create PR → CI rebuilds (8min) ✅
```
**Time saved: 5 minutes** by catching Docker issues locally
@@ -170,20 +210,27 @@ Layer 3: CI (REQUIRED) → Final verification (~5 minutes)
**For most changes:**
```bash
# 1. Make your changes
# 1. Create feature branch
git checkout -b feature/my-feature
# 2. Make your changes
vim app/SomeFile.php
# 2. Commit (formatting happens automatically)
# 3. Commit (formatting happens automatically)
git add .
git commit -m "feat(scope): description"
→ Pre-commit runs Laravel Pint ✅
→ Code formatted automatically ✅
# 3. Push (tests run automatically)
git push origin master
# 4. Push (tests run automatically)
git push origin feature/my-feature
→ Pre-push runs tests (30 seconds) ✅
→ If tests pass, push continues ✅
# 5. Create Pull Request
→ Open PR on Gitea to merge into develop
→ CI verifies everything (5 minutes) ✅
→ After review, merge PR
```
**For quick documentation changes:**
@@ -192,6 +239,163 @@ git push origin master
git push --no-verify
```
### Keeping Your Feature Branch Up-to-Date
**Best practice for teams:** Sync your feature branch with `develop` regularly to avoid large merge conflicts.
#### Daily Start-of-Work Routine
```bash
# 1. Get latest changes from develop
git checkout develop
git pull origin develop
# 2. Update your feature branch
git checkout feature/my-feature
git merge develop
# 3. If there are conflicts (see below), resolve them
# 4. Continue working
```
**How often?**
- Minimum: Once per day (start of work)
- Better: Multiple times per day if develop is active
- Always: Before creating your Pull Request
#### Merge vs Rebase: Which to Use?
**For teams of 5+ developers, use `merge` (not `rebase`):**
```bash
git checkout feature/my-feature
git merge develop
```
**Why merge over rebase?**
- ✅ Safer: Preserves your commit history
- ✅ Collaborative: Works when multiple people work on the same feature branch
- ✅ Transparent: Shows when you integrated upstream changes
- ✅ No force-push: Once you've pushed to origin, merge won't require `--force`
**When to use rebase:**
- ⚠️ Only if you haven't pushed yet
- ⚠️ Only if you're the sole developer on the branch
- ⚠️ You want a cleaner, linear history
```bash
# Only do this if you haven't pushed yet!
git checkout feature/my-feature
git rebase develop
```
**Never rebase after pushing** - it rewrites history and breaks collaboration.
#### Handling Merge Conflicts
When you run `git merge develop` and see conflicts:
```bash
$ git merge develop
Auto-merging app/Http/Controllers/OrderController.php
CONFLICT (content): Merge conflict in app/Http/Controllers/OrderController.php
Automatic merge failed; fix conflicts and then commit the result.
```
**Step-by-step resolution:**
1. **See which files have conflicts:**
```bash
git status
# Look for "both modified:" files
```
2. **Open conflicted files** - look for conflict markers:
```php
<<<<<<< HEAD
// Your code
=======
// Code from develop
>>>>>>> develop
```
3. **Resolve conflicts** - edit the file to keep what you need:
```php
// Choose your code, their code, or combine both
// Remove the <<<, ===, >>> markers
```
4. **Mark as resolved:**
```bash
git add app/Http/Controllers/OrderController.php
```
5. **Complete the merge:**
```bash
git commit -m "merge: resolve conflicts with develop"
```
6. **Run tests to ensure nothing broke:**
```bash
./vendor/bin/sail artisan test
```
7. **Push the merge commit:**
```bash
git push origin feature/my-feature
```
#### When Conflicts Are Too Complex
If conflicts are extensive or you're unsure:
1. **Abort the merge:**
```bash
git merge --abort
```
2. **Ask for help** in #engineering Slack:
- "I'm merging develop into feature/X and have conflicts in OrderController"
- Someone might have context on the upstream changes
3. **Pair program the resolution** - screen share with the person who made the conflicting changes
4. **Alternative: Start fresh** (last resort):
```bash
# Create new branch from latest develop
git checkout develop
git pull origin develop
git checkout -b feature/my-feature-v2
# Cherry-pick your commits
git cherry-pick <commit-hash>
```
#### Example: Multi-Day Feature Work
```bash
# Monday morning
git checkout develop && git pull origin develop
git checkout feature/payment-integration
git merge develop # Get latest changes
# Work all day, make commits
# Tuesday morning
git checkout develop && git pull origin develop
git checkout feature/payment-integration
git merge develop # Sync again (someone added auth changes)
# Continue working
# Wednesday
git checkout develop && git pull origin develop
git checkout feature/payment-integration
git merge develop # Final sync before PR
git push origin feature/payment-integration
# Create Pull Request
```
**Result:** Small, manageable syncs instead of one huge conflict on PR day.
### When to Test Locally
**Always run tests before pushing if you:**
@@ -251,7 +455,7 @@ git commit -m "chore: upgrade Laravel to 11.x"
## When CI Fails
### Step 1: Check What Failed
Visit: `https://ci.cannabrands.com/repos/1`
Visit: `https://ci.cannabrands.app/repos/1`
### Step 2: Reproduce Locally
```bash
@@ -276,7 +480,7 @@ vim app/SomeFile.php
# Push fix
git add .
git commit -m "fix: resolve test failure"
git push origin master
git push origin feature/my-feature # Push to your feature branch
```
---
@@ -304,9 +508,9 @@ git commit --no-verify # Skip formatting (fix in next commit)
**❌ Skipping because tests fail** → Fix the tests instead
**❌ Skipping to avoid formatting** → Let Pint format it
**❌ Skipping on master before deploy** → CI will block you anyway
**❌ Skipping to merge PR to develop/master** → CI will block you anyway
**Remember:** CI can't be bypassed, so issues will be caught before production.
**Remember:** CI can't be bypassed, and develop/master are protected branches requiring PRs and passing CI.
---
@@ -321,7 +525,7 @@ git config core.hooksPath .githooks
### How it works:
```bash
$ git push origin master
$ git push origin feature/my-feature
🧪 Running tests before push...
(Use 'git push --no-verify' to skip)
@@ -347,8 +551,8 @@ docker build -t cannabrands:test .
# If successful, test run it
docker run --rm cannabrands:test php -v
# Then push
git push origin master
# Then push to feature branch
git push origin feature/my-feature
```
**Why?** Docker builds take 5-10 minutes in CI vs 2-3 minutes locally.
@@ -383,17 +587,23 @@ If you're responsible for creating releases, see:
# 1. Determine version (CalVer: YYYY.MM.MICRO)
git tag -l "2025.11.*" | sort -V | tail -1 # Check latest
# 2. Create release tag
# 2. Ensure you're on master and up-to-date
git checkout master
git pull origin master
# 3. Create release tag on master
git tag -a 2025.11.1 -m "Release notes here"
git push origin 2025.11.1
# 3. CI builds production image automatically
# 4. CI builds production image automatically
# 4. Generate changelog
# 5. Generate changelog (create PR for this)
git checkout -b chore/changelog-2025.11.1
npm run changelog
git add CHANGELOG.md
git commit -m "docs: update changelog for 2025.11.1"
git push origin master
git push origin chore/changelog-2025.11.1
# Create PR to merge into master
```
---
@@ -419,8 +629,8 @@ git push origin master
- Pair program for complex changes
### Services
- **Woodpecker CI:** `https://ci.cannabrands.com`
- **Gitea:** `https://code.cannabrands.com`
- **Woodpecker CI:** `https://ci.cannabrands.app`
- **Gitea:** `https://code.cannabrands.app`
- **Production:** `https://app.cannabrands.com` (future)
---
@@ -455,7 +665,7 @@ Trust the process, and the process will catch your mistakes before they reach pr
Consider adding:
- Code review requirement for certain files
- Protected branches (master requires PR)
- Protected branches already in place (develop/master require PRs)
- Mandatory tests on pre-push (harder to skip)
### If You Reach 50+ developers

View File

@@ -7,6 +7,18 @@ FROM node:22-alpine AS node-builder
WORKDIR /app
# Accept Vite environment variables as build arguments
ARG VITE_REVERB_APP_KEY
ARG VITE_REVERB_HOST
ARG VITE_REVERB_PORT=443
ARG VITE_REVERB_SCHEME=https
# Export as environment variables for Vite build
ENV VITE_REVERB_APP_KEY=${VITE_REVERB_APP_KEY}
ENV VITE_REVERB_HOST=${VITE_REVERB_HOST}
ENV VITE_REVERB_PORT=${VITE_REVERB_PORT}
ENV VITE_REVERB_SCHEME=${VITE_REVERB_SCHEME}
# Copy package files
COPY package*.json ./
@@ -18,7 +30,7 @@ COPY resources ./resources
COPY vite.config.js tailwind.config.js ./
COPY public ./public
# Build frontend assets
# Build frontend assets (Vite will inline VITE_* env vars)
RUN npm run build
# ==================== Stage 2: Composer Builder ====================
@@ -56,6 +68,8 @@ RUN apk add --no-cache \
libjpeg-turbo-dev \
freetype-dev \
libzip-dev \
icu-dev \
icu-data-full \
zip \
unzip \
git \
@@ -75,6 +89,7 @@ RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
pgsql \
gd \
zip \
intl \
pcntl \
bcmath \
opcache
@@ -100,6 +115,9 @@ COPY --from=node-builder --chown=www-data:www-data /app/public/build ./public/bu
# Copy vendor from composer-builder
COPY --from=composer-builder --chown=www-data:www-data /app/vendor ./vendor
# Note: Asset publishing runs in init container at runtime (not build time)
# Artisan commands require environment variables which aren't available during build
# Create version metadata file
RUN echo "VERSION=${APP_VERSION}" > /var/www/html/version.env && \
echo "COMMIT=${GIT_COMMIT_SHA}" >> /var/www/html/version.env && \
@@ -109,17 +127,21 @@ RUN echo "VERSION=${APP_VERSION}" > /var/www/html/version.env && \
COPY docker/production/nginx/default.conf /etc/nginx/http.d/default.conf
COPY docker/production/supervisor/supervisord.conf /etc/supervisor/supervisord.conf
COPY docker/production/php/php.ini /usr/local/etc/php/conf.d/99-custom.ini
COPY docker/production/php/php-fpm.conf /usr/local/etc/php-fpm.d/zz-custom.conf
# Remove default PHP-FPM pool config and use our custom one
RUN rm -f /usr/local/etc/php-fpm.d/www.conf /usr/local/etc/php-fpm.d/www.conf.default
COPY docker/production/php/php-fpm.conf /usr/local/etc/php-fpm.d/www.conf
# Create directories
RUN mkdir -p /var/www/html/storage /var/www/html/bootstrap/cache \
RUN mkdir -p /var/www/html/storage/framework/{sessions,views,cache} \
&& mkdir -p /var/www/html/storage/logs \
&& mkdir -p /var/www/html/bootstrap/cache \
&& mkdir -p /var/log/supervisor \
&& chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache \
&& chmod -R 775 /var/www/html/storage /var/www/html/bootstrap/cache
# Optimize Laravel
RUN php artisan config:cache || true \
&& php artisan route:cache || true \
&& php artisan view:cache || true
# Note: Skip Laravel caching at build time since runtime config will be different
# Cache will be generated at runtime with actual environment variables
# Expose port
EXPOSE 80

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,716 +0,0 @@
# 🚀 Cannabrands CRM → New Platform Migration Master Plan
**Project**: Migration from Laravel 9 + VentureDrake CRM to Laravel 12 + Filament 4
**Timeline**: 28 days (End of Month Launch)
**Scope**: Full data migration with feature parity for core commerce features
**Risk Level**: Medium (greenfield rebuild, but parallel operation possible)
---
## 📊 Executive Summary
### Current State
- **Old System**: Laravel 9, VentureDrake CRM, MySQL, ~1000 SKUs, 20 users, 5 brands
- **Problem**: Vendor file modifications (100+ commits), blocked Laravel upgrades, maintenance nightmare
- **Data**: 1+ year operational history, ~1000 products, 20 active users, ongoing orders
### Target State
- **New System**: Laravel 12, Filament 4, PostgreSQL, clean architecture
- **Architecture**: License → Company → Brands → Stores (LeafLink model)
- **Launch Date**: End of month (~28 days)
- **Cannabrands Brands**: Doobz, Thunderbud, High Expectations, Hash Factory, Twisties
### Migration Strategy
**Two-track parallel development:**
1. **New Platform Development**: Build features in new codebase (weeks 1-3)
2. **Data Migration**: Import existing data from old system (week 4)
3. **Parallel Operation**: Old system stays live until cutover
---
## 🎯 Business Goals
### Primary Objectives (Launch Blockers)
1. ✅ **Product Catalog**: 1000 SKUs with strains, lab results, varieties
2. ✅ **Shopping Cart + Checkout**: Buyer portal commerce flow
3. ✅ **Order Management**: Order lifecycle, statuses, fulfillment
4. ✅ **Invoice Generation**: Invoice creation + payment tracking
5. ✅ **Buyer Application**: Company signup with compliance approval
6. ✅ **Company/Location Management**: Multi-location buyers
7. ✅ **Component/BOM System**: Build SKUs from components
### Post-Launch Features (Deferred)
- ⏸️ Raffles system
- ⏸️ Sample requests
- ⏸️ Feedback system
### Success Criteria
- All 1000 SKUs migrated and searchable
- All 20 users can log in with existing credentials
- Historical orders visible and accessible
- New orders can be placed and fulfilled
- Invoices generate correctly
- Zero data loss
---
## 🏗️ Architecture Transformation
### Old System Architecture
```
User (Laravel Auth)
└─> CrmContact (company_contacts)
└─> CrmCompany (companies) - BUYER
Cannabrands (hardcoded seller)
└─> CrmProduct (products)
└─> CrmInvoice (type='order')
```
### New System Architecture
```
Company (License Holder)
├─> type: 'seller' (Cannabrands)
│ └─> Brands (Stores)
│ ├─> Doobz
│ ├─> Thunderbud
│ ├─> High Expectations
│ ├─> Hash Factory
│ └─> Twisties
│ └─> Products (SKUs)
│ └─> Components (BOM)
└─> type: 'buyer' (Dispensaries)
├─> Locations (delivery addresses)
└─> Contacts (users)
└─> Orders → Invoices
```
### Key Changes
1. **Rename**: `businesses` table → `companies` table (better terminology)
2. **Multi-brand**: Cannabrands operates 5 brands (stores) under one license
3. **Separation**: Products belong to Brands, not Companies
4. **Dual-purpose removed**: Split `crm_invoices` (type='order'|'invoice') into separate tables
5. **Clean schema**: No VentureDrake CRM bloat (70% unused tables eliminated)
---
## 📅 4-Week Implementation Timeline
### Week 1: Foundation & Core Models (Days 1-7)
**Goal**: Database schema + authentication ready
#### Days 1-2: Schema Refactoring
- [ ] Rename `businesses``companies` (migration + models)
- [ ] Create `brands` table and Brand model
- [ ] Create `products` table (clean, no CRM dependency)
- [ ] Create `components` table (BOM system)
- [ ] Create `orders` table (separate from invoices)
- [ ] Create `order_items` table (line items)
- [ ] Update all relationships and foreign keys
#### Days 3-4: Authentication & Users
- [ ] User migration strategy (preserve passwords)
- [ ] Spatie Permission roles: admin, company-owner, company-user, buyer
- [ ] Contact model (user relationships)
- [ ] Location model (delivery addresses)
- [ ] License model (compliance tracking)
#### Days 5-7: Filament Admin Panel Setup
- [ ] CompanyResource (CRUD for license holders)
- [ ] BrandResource (store management for sellers)
- [ ] UserResource (user management)
- [ ] Dashboard widgets (overview stats)
- [ ] Navigation structure
**Milestone 1 Deliverable**: Can create companies, brands, users in Filament
---
### Week 2: Product Catalog & BOM (Days 8-14)
**Goal**: 1000 SKUs ready to migrate
#### Days 8-10: Product System
- [ ] ProductResource in Filament
- Form: name, SKU, description, price, strain, lab
- Table: searchable, filterable by brand
- RelationManager: ProductImages, ProductPrices
- [ ] StrainResource (cannabis strains)
- [ ] LabResource (lab test results - THC/CBD)
- [ ] ProductCategoryResource (taxonomy)
- [ ] Product varieties system (parent-child products)
#### Days 11-12: Component/BOM System
- [ ] ComponentResource (raw materials)
- [ ] Product → Components relationship (junction table)
- [ ] BOM calculator (cost calculation)
- [ ] Inventory tracking (if needed)
#### Days 13-14: Product Import Preparation
- [ ] Extract old product data structure
- [ ] Create product mapping script (old SKU → new product)
- [ ] Create component extraction script
- [ ] Build product seeder from old database
- [ ] Test import with 50 sample products
**Milestone 2 Deliverable**: Can create/manage products with components
---
### Week 3: Commerce & Orders (Days 15-21)
**Goal**: Full buyer purchasing flow working
#### Days 15-16: Shopping Cart (Buyer Portal)
- [ ] Cart model + session management
- [ ] Add to cart functionality
- [ ] Cart display page (`/b/cart`)
- [ ] Update quantities, remove items
- [ ] Cart persistence (logged-in users)
#### Days 17-18: Checkout Flow
- [ ] Checkout page (`/b/checkout`)
- [ ] Select delivery location
- [ ] Payment terms selection (COD, Net 15/30/60/90)
- [ ] Order preview and confirmation
- [ ] Order creation from cart
#### Days 19-20: Order Management
- [ ] OrderResource in Filament
- Table: order number, buyer, total, status, date
- Form: view order details, line items
- Actions: Accept, Fulfill, Deliver, Cancel
- [ ] Order status workflow (new → accepted → fulfilled → delivered)
- [ ] Email notifications (order placed, status changes)
- [ ] Buyer order history page (`/b/orders`)
#### Day 21: Invoice System
- [ ] InvoiceResource in Filament
- [ ] Generate invoice from order
- [ ] Invoice PDF generation
- [ ] Payment tracking (paid/unpaid status)
- [ ] Invoice email delivery
**Milestone 3 Deliverable**: Can place order, fulfill, generate invoice
---
### Week 4: Data Migration & Launch (Days 22-28)
**Goal**: Old data migrated, system live
#### Days 22-23: Data Migration - Phase 1 (Companies & Users)
- [ ] Export old database schema
- [ ] Create Cannabrands as Company #1 (type='seller')
- [ ] Create 5 brands linked to Cannabrands
- Brand 1: Doobz
- Brand 2: Thunderbud
- Brand 3: High Expectations
- Brand 4: Hash Factory
- Brand 5: Twisties
- [ ] Migrate buyer companies (old `companies` → new `companies` type='buyer')
- [ ] Migrate users (preserve password hashes)
- [ ] Migrate contacts → users relationship
- [ ] Migrate locations (delivery addresses)
#### Days 24-25: Data Migration - Phase 2 (Products & Catalog)
- [ ] Migrate strains table (direct copy)
- [ ] Migrate labs table (direct copy)
- [ ] Migrate components (BOM data)
- [ ] Migrate products (1000 SKUs)
- Map old `crm_products` → new `products`
- Assign products to appropriate brand (need brand mapping logic)
- Migrate product images
- Migrate product prices
- Migrate product varieties
- [ ] Verify product data integrity (spot checks)
#### Day 26: Data Migration - Phase 3 (Orders & Invoices)
- [ ] Migrate historical orders (all time)
- Old `crm_invoices` (type='order') → new `orders`
- Old `crm_invoice_lines` → new `order_items`
- Preserve order statuses and dates
- [ ] Migrate invoices
- Old `crm_invoices` (type='invoice') → new `invoices`
- Link to corresponding orders
- [ ] Migrate invoice payments
- [ ] Verify order totals match
#### Day 27: Testing & Bug Fixes
- [ ] End-to-end testing
- Register new buyer account
- Browse products by brand
- Add to cart, checkout
- Place order
- Admin: accept, fulfill order
- Generate invoice
- Record payment
- [ ] User acceptance testing (UAT) with Cannabrands team
- [ ] Performance testing (1000 products, 20 concurrent users)
- [ ] Fix critical bugs
- [ ] Data integrity verification
#### Day 28: Launch & Cutover
- [ ] Final data sync (if parallel operation)
- [ ] DNS/domain cutover
- [ ] SSL certificate setup
- [ ] Monitor error logs
- [ ] User training documentation
- [ ] Announce launch to users
- [ ] Post-launch support monitoring
**Milestone 4 Deliverable**: New system live, old system retired
---
## 🗄️ Data Migration Detailed Plan
### Migration Tools
- **Laravel Commands**: Custom Artisan commands for each data type
- **Direct SQL**: For bulk operations (faster)
- **Seeders**: For reference data (strains, categories)
- **Validation**: Hash comparisons to verify data integrity
### Migration Order (Critical Dependencies)
```
1. Companies (license holders) - no dependencies
2. Brands (stores) - depends on companies
3. Users - depends on companies
4. Contacts - depends on users + companies
5. Locations - depends on companies
6. Strains - no dependencies
7. Labs - no dependencies
8. Components - no dependencies
9. Products - depends on brands, strains, labs
10. Product Components - depends on products, components
11. Orders - depends on companies (buyer), users, products
12. Order Items - depends on orders, products
13. Invoices - depends on orders
14. Invoice Payments - depends on invoices
```
### Data Transformation Scripts
**Location**: `/Users/jon/projects/cannabrands/cannabrands_new/database/migrations/data/`
**Scripts to Create**:
1. `migrate_companies.php` - Companies + Cannabrands setup
2. `migrate_brands.php` - 5 Cannabrands brands
3. `migrate_users.php` - User accounts (preserve passwords)
4. `migrate_products.php` - 1000 SKUs with relationships
5. `migrate_orders.php` - Historical orders + invoices
6. `verify_migration.php` - Data integrity checks
### Password Preservation
```php
// Old system uses Laravel Hash (bcrypt)
// New system uses Laravel Hash (bcrypt)
// Direct copy of password hashes works
User::create([
'email' => $oldUser->email,
'password' => $oldUser->password, // Direct copy - NO rehashing
'email_verified_at' => $oldUser->email_verified_at,
]);
```
### Rollback Strategy
- Keep old system running in parallel for 2 weeks post-launch
- Database snapshots before each migration phase
- Export old database to SQL dump (backup)
- Document rollback commands for each migration script
---
## 🎨 Filament Resources Specification
### 1. CompanyResource
**Purpose**: Manage license holders (buyers + sellers)
**Table Columns**:
- Name (searchable)
- Type (badge: buyer/seller/both)
- License # (searchable)
- Status (badge: active/pending/suspended)
- Created date
**Form Fields**:
- Business Information: name, DBA, legal name, type
- License: number, expiry, document upload
- Contact: email, phone, address
- Compliance: W9, insurance, cannabis license
**Relations**:
- Brands (HasMany) - for sellers
- Locations (HasMany)
- Contacts (HasMany)
- Users (BelongsToMany via pivot)
**Actions**:
- Approve Company
- Suspend Company
- Download Compliance Docs
---
### 2. BrandResource
**Purpose**: Manage stores (product catalogs under a company)
**Table Columns**:
- Logo (image)
- Name (searchable)
- Company (relationship)
- Product count
- Status (active/inactive)
**Form Fields**:
- Brand Identity: name, slug, logo, description
- Company (BelongsTo selector)
- Social Media: Instagram, website
- Settings: active status
**Relations**:
- Products (HasMany)
- Company (BelongsTo)
**Actions**:
- View Storefront
- Clone Brand
- Activate/Deactivate
---
### 3. ProductResource
**Purpose**: Manage SKUs (products sold on platform)
**Table Columns**:
- Image (thumbnail)
- SKU (searchable)
- Name (searchable)
- Brand (relationship)
- Strain (relationship)
- Price (money format)
- Stock status
**Form Fields**:
- Product Info: name, SKU, description
- Brand (BelongsTo selector)
- Pricing: base price, quantity breaks
- Cannabis: Strain (BelongsTo), Lab results (BelongsTo)
- Media: Images (multiple upload)
- Inventory: track stock, quantity
**Relations**:
- Brand (BelongsTo)
- Strain (BelongsTo)
- Lab (BelongsTo)
- Components (BelongsToMany) - BOM
- ProductImages (HasMany)
- ProductPrices (HasMany)
- Varieties (HasMany) - parent/child products
**Actions**:
- Clone Product
- Generate Barcode
- Export to CSV
- Activate/Deactivate
---
### 4. OrderResource
**Purpose**: Manage buyer purchase orders
**Table Columns**:
- Order # (searchable)
- Buyer Company (relationship)
- Brand (relationship)
- Total (money format)
- Status (badge with colors)
- Order date (sortable)
**Form Fields**:
- Order Info: number, date, buyer, location
- Line Items: Repeater (product, quantity, price)
- Totals: subtotal, tax, total
- Status: workflow selector
- Notes: internal notes
**Relations**:
- Company (BelongsTo) - buyer
- User (BelongsTo) - who placed it
- Location (BelongsTo) - delivery address
- OrderItems (HasMany)
- Invoice (HasOne)
**Actions**:
- Accept Order
- Mark as Fulfilled
- Mark as Delivered
- Cancel Order
- Generate Invoice
- Email Customer
**Status Workflow**:
```
new → accepted → fulfilled → delivered
↓ ↓ ↓
cancelled
```
---
### 5. ComponentResource (BOM System)
**Purpose**: Manage raw materials used to build products
**Table Columns**:
- Name (searchable)
- Type (badge: flower/extract/packaging)
- Unit cost (money)
- Unit (oz/g/each)
- Stock
**Form Fields**:
- Component Info: name, description, type
- Pricing: cost per unit, unit of measure
- Inventory: current stock, reorder point
- Supplier: supplier info (optional)
**Relations**:
- Products (BelongsToMany via product_components)
**Actions**:
- View Products Using This Component
- Update Cost
---
## 🔒 Security & Compliance
### Data Protection
- **Password hashes**: Direct copy (bcrypt compatible)
- **Sensitive documents**: Migrate file paths, verify file existence
- **License data**: Encrypted at rest in new system
- **Payment info**: PCI compliance (if storing cards)
### Access Control
**Roles** (Spatie Permission):
- `admin` - Platform administrators (you/your team)
- `company-owner` - Company account owner (full company access)
- `company-manager` - Can manage orders, products (limited)
- `company-user` - Can place orders, view history (buyer role)
**Permissions**:
- `companies.view`, `companies.create`, `companies.edit`, `companies.delete`
- `brands.manage` - Create/edit brands (seller only)
- `products.manage` - Manage product catalog (seller only)
- `orders.place` - Place orders (buyer only)
- `orders.manage` - Accept/fulfill orders (seller only)
- `invoices.view`, `invoices.generate`
---
## 📈 Performance Optimization
### Database Indexes (Critical for 1000 SKUs)
```sql
-- Products table
CREATE INDEX idx_products_brand_id ON products(brand_id);
CREATE INDEX idx_products_sku ON products(sku);
CREATE INDEX idx_products_strain_id ON products(strain_id);
-- Orders table
CREATE INDEX idx_orders_company_id ON orders(company_id);
CREATE INDEX idx_orders_status ON orders(status);
CREATE INDEX idx_orders_created_at ON orders(created_at);
-- Companies table
CREATE INDEX idx_companies_type ON companies(type);
CREATE INDEX idx_companies_license ON companies(license_number);
```
### Caching Strategy
- **Product catalog**: Cache for 1 hour
- **Brand data**: Cache for 24 hours
- **Shopping cart**: Session-based (no DB queries)
- **Order history**: Cache per user (invalidate on new order)
### Eager Loading (Prevent N+1 Queries)
```php
// Products with relationships
Product::with(['brand', 'strain', 'lab', 'images', 'prices'])->get();
// Orders with line items
Order::with(['items.product', 'company', 'location'])->get();
```
---
## 🧪 Testing Strategy
### Unit Tests (Pest)
- [ ] Product model relationships
- [ ] Order total calculations
- [ ] BOM cost calculations
- [ ] User authentication
- [ ] Permission checks
### Feature Tests
- [ ] User registration flow
- [ ] Company approval workflow
- [ ] Product CRUD operations
- [ ] Shopping cart functionality
- [ ] Order placement end-to-end
- [ ] Invoice generation
### Migration Verification Tests
- [ ] User count matches (20 users)
- [ ] Product count matches (~1000 SKUs)
- [ ] Order totals match historical data
- [ ] Company count matches
- [ ] All file paths resolve (images, documents)
### Load Testing
- [ ] 1000 products loaded in catalog page (<2s)
- [ ] 20 concurrent users placing orders
- [ ] Search performance with 1000 SKUs (<500ms)
---
## ⚠️ Risk Mitigation
### High-Risk Items
**1. Password Migration**
- **Risk**: Users can't log in if hashes incompatible
- **Mitigation**: Test migration with 3 sample users first
- **Rollback**: Keep old system for password resets
**2. Product-to-Brand Mapping**
- **Risk**: Old system doesn't track which brand owns which product
- **Mitigation**: Manual mapping CSV: product_id → brand_name
- **Fallback**: Assign all to "Cannabrands General" brand initially
**3. Order Data Integrity**
- **Risk**: Order totals don't match after migration
- **Mitigation**: Checksum verification on order totals
- **Testing**: Compare 10 random orders old vs new
**4. File Path Migration**
- **Risk**: Product images, compliance docs not found
- **Mitigation**: Copy entire storage directory
- **Verification**: Script to check all file paths resolve
**5. Email Notifications**
- **Risk**: Spamming users during migration testing
- **Mitigation**: Use Mailpit, disable SMTP until launch
- **Testing**: Check email queue, don't send during migration
### Medium-Risk Items
- Component data incomplete (some products missing BOM)
- Variety relationships complex (parent-child products)
- Historical data too large (performance issues)
---
## 📋 Launch Checklist
### Pre-Launch (Day 27)
- [ ] All 1000 products migrated and visible
- [ ] All 20 users can log in successfully
- [ ] Test order placement end-to-end (3 different buyers)
- [ ] Filament admin panel accessible
- [ ] Email notifications working (test mode)
- [ ] SSL certificate installed
- [ ] Database backups automated
- [ ] Error monitoring setup (Sentry/Bugsnag)
### Launch Day (Day 28)
- [ ] Final data sync from old database
- [ ] DNS cutover to new system
- [ ] Announce to users via email
- [ ] Monitor error logs (first 2 hours)
- [ ] Support team on standby
- [ ] Old system in read-only mode (fallback)
### Post-Launch (Week 5)
- [ ] Daily monitoring for 1 week
- [ ] User feedback collection
- [ ] Bug fix hot patches
- [ ] Performance tuning based on real usage
- [ ] Retire old system after 2 weeks stable operation
---
## 🛠️ Development Tools & Resources
### Required Packages (Already Installed)
- ✅ Laravel 12
- ✅ Filament 4
- ✅ Spatie Permission v6
- ✅ Laravel Breeze (auth scaffolding)
- ✅ Pest (testing)
### Additional Packages Needed
```bash
composer require barryvdh/laravel-dompdf # Invoice PDFs
composer require intervention/image # Image processing
composer require maatwebsite/excel # CSV imports/exports
```
### Development Commands
```bash
# New project
cd /Users/jon/projects/cannabrands/cannabrands_new
# Old project (reference only)
cd /Users/jon/projects/cannabrands/cannabrands_crm
```
---
## 📞 Support & Communication
### Daily Standup (Recommended)
- What was completed yesterday
- What's planned for today
- Any blockers
### Claude Code Collaboration
- Reference old codebase: `/Users/jon/projects/cannabrands/cannabrands_crm`
- Active development: `/Users/jon/projects/cannabrands/cannabrands_new`
- Use CLAUDE_COLLABORATION_WORKFLOW.md for prompt templates
### Decision Log
| Date | Decision | Rationale |
|------|----------|-----------|
| Today | Use "companies" not "businesses" | Better industry terminology |
| Today | 5 brands under Cannabrands | Matches business structure |
| Today | Separate orders/invoices tables | Cleaner than dual-purpose |
| Today | PostgreSQL for new system | Better JSON support, scalability |
---
## ✅ Success Metrics
### Technical Metrics
- Zero data loss (100% of records migrated)
- <2s page load for product catalog
- <500ms search response time
- 99.9% uptime in first month
### Business Metrics
- All 5 Cannabrands brands have active storefronts
- 20/20 users successfully migrated and active
- First order placed within 48 hours of launch
- Invoice generation working for all brands
### User Satisfaction
- User training completed (1 hour session)
- <5 support tickets in first week
- Positive feedback from Cannabrands team
- Buyers can easily find products
---
**Last Updated**: December 2024
**Owner**: Cannabrands Development Team
**Status**: Ready for Execution
**Next Step**: Begin Week 1 - Schema Refactoring

195
Makefile
View File

@@ -1,8 +1,45 @@
.PHONY: help dev dev-down dev-build dev-shell dev-logs dev-vite prod-build prod-up prod-down prod-logs prod-shell prod-vite migrate test clean install
.PHONY: help dev dev-down dev-build dev-shell dev-logs dev-vite k-dev k-down k-logs k-shell k-artisan k-composer k-vite k-status prod-build prod-up prod-down prod-logs prod-shell prod-vite prod-test prod-test-build prod-test-up prod-test-down prod-test-logs prod-test-shell prod-test-status prod-test-clean migrate test clean install
# Default target
.DEFAULT_GOAL := help
# ==================== K8s Variables ====================
# K3d cluster must be created with dual volume mounts:
# k3d cluster create dev \
# --api-port 6443 \
# --port "80:80@loadbalancer" \
# --port "443:443@loadbalancer" \
# --volume /Users/jon/projects/cannabrands/cannabrands_new/.worktrees:/worktrees \
# --volume /Users/jon/projects/cannabrands/cannabrands_new:/project-root \
# --volume k3d-dev-images:/k3d/images
# Detect if we're in a worktree or project root
GIT_DIR := $(shell git rev-parse --git-dir 2>/dev/null)
IS_WORKTREE := $(shell echo "$(GIT_DIR)" | grep -q ".worktrees" && echo "true" || echo "false")
# Set paths based on location
ifeq ($(IS_WORKTREE),true)
# In a worktree - use worktree-specific path
WORKTREE_NAME := $(shell basename $(CURDIR))
K8S_VOLUME_PATH := /worktrees/$(WORKTREE_NAME)
else
# In project root - use root path
WORKTREE_NAME := root
K8S_VOLUME_PATH := /project-root
endif
# Generate namespace from branch name (feat-branch-name)
CURRENT_BRANCH := $(shell git rev-parse --abbrev-ref HEAD)
K8S_NS := $(shell echo "$(CURRENT_BRANCH)" | sed 's/feature\//feat-/' | sed 's/bugfix\//fix-/' | sed 's/\//-/g')
# Generate sanitized branch name for database
SANITIZED_BRANCH := $(shell echo "$(CURRENT_BRANCH)" | sed 's/[^a-zA-Z0-9]/_/g')
# Generate host from branch
K8S_HOST := $(shell echo "$(CURRENT_BRANCH)" | sed 's/feature\///' | sed 's/bugfix\///' | sed 's/\//-/g').cannabrands.test
# Read database credentials from .env
DB_USERNAME := $(shell grep '^DB_USERNAME=' .env 2>/dev/null | cut -d '=' -f2)
DB_PASSWORD := $(shell grep '^DB_PASSWORD=' .env 2>/dev/null | cut -d '=' -f2)
DB_DATABASE := $(shell grep '^DB_DATABASE=' .env 2>/dev/null | cut -d '=' -f2)
# ==================== Local Development (Sail) ====================
dev: ## Start local development environment with Sail
./vendor/bin/sail up -d
@@ -31,6 +68,94 @@ dev-composer: ## Run composer command (usage: make dev-composer CMD="install")
dev-vite: ## Start Vite dev server (run after 'make dev')
./vendor/bin/sail npm run dev
# ==================== K8s Local Development ====================
k-dev: ## Start k8s local environment (like Sail, but with namespace isolation)
@echo "🚀 Starting k8s environment"
@echo " Location: $(if $(filter true,$(IS_WORKTREE)),Worktree ($(WORKTREE_NAME)),Project Root)"
@echo " Namespace: $(K8S_NS)"
@echo " Branch: $(CURRENT_BRANCH)"
@echo " URL: http://$(K8S_HOST)"
@echo ""
@# Create namespace
@kubectl create ns $(K8S_NS) --dry-run=client -o yaml | kubectl apply -f -
@# Create secrets from .env
@kubectl -n $(K8S_NS) delete secret app-env --ignore-not-found
@kubectl -n $(K8S_NS) create secret generic app-env --from-env-file=.env
@# Create PostgreSQL auth secret (using credentials from .env)
@kubectl -n $(K8S_NS) create secret generic pg-auth --dry-run=client -o yaml \
--from-literal=POSTGRES_DB=$(DB_DATABASE) \
--from-literal=POSTGRES_USER=$(DB_USERNAME) \
--from-literal=POSTGRES_PASSWORD=$(DB_PASSWORD) | kubectl apply -f -
@# Deploy PostgreSQL
@export NS=$(K8S_NS) PG_DB=$(DB_DATABASE) PG_USER=$(DB_USERNAME) PG_PASS=$(DB_PASSWORD) && \
envsubst < k8s/local/postgres.yaml | kubectl apply -f -
@# Deploy Redis
@export NS=$(K8S_NS) && \
envsubst < k8s/local/redis.yaml | kubectl apply -f -
@# Deploy Reverb (WebSocket server)
@export NS=$(K8S_NS) K8S_VOLUME_PATH=$(K8S_VOLUME_PATH) K8S_HOST=$(K8S_HOST) && \
envsubst < k8s/local/reverb.yaml | kubectl apply -f -
@# Wait for DB
@echo "⏳ Waiting for PostgreSQL..."
@kubectl -n $(K8S_NS) wait --for=condition=ready pod -l app=postgres --timeout=60s
@# Deploy app (with code volume mounted)
@export NS=$(K8S_NS) K8S_VOLUME_PATH=$(K8S_VOLUME_PATH) K8S_HOST=$(K8S_HOST) && \
envsubst < k8s/local/deployment.yaml | kubectl apply -f -
@# Create service + ingress
@export NS=$(K8S_NS) K8S_HOST=$(K8S_HOST) && \
envsubst < k8s/local/service.yaml | kubectl apply -f - && \
envsubst < k8s/local/ingress.yaml | kubectl apply -f -
@echo ""
@echo "✅ Ready! Visit: http://$(K8S_HOST)"
@echo ""
@echo "💡 Your code is volume-mounted - changes are instant!"
@echo " Edit files → refresh browser → see changes"
@echo ""
@echo "📝 Useful commands:"
@echo " make k-logs # View app logs"
@echo " make k-shell # Open shell in pod"
@echo " make k-vite # Start Vite dev server"
@echo ""
@echo "🔌 WebSocket (Reverb) available at: ws://reverb.$(K8S_HOST):8080"
k-down: ## Stop k8s environment
@echo "🗑 Removing namespace: $(K8S_NS)"
@kubectl delete ns $(K8S_NS) --ignore-not-found
@echo "✅ Cleaned up"
k-logs: ## View app logs
@kubectl -n $(K8S_NS) logs -f deploy/web --all-containers=true
k-shell: ## Shell into app container
@kubectl -n $(K8S_NS) exec -it deploy/web -- /bin/bash
k-artisan: ## Run artisan command (usage: make k-artisan CMD="migrate")
@kubectl -n $(K8S_NS) exec deploy/web -- php artisan $(CMD)
k-composer: ## Run composer (usage: make k-composer CMD="install")
@kubectl -n $(K8S_NS) exec deploy/web -- composer $(CMD)
k-vite: ## Run Vite dev server in k8s pod
@echo "🎨 Starting Vite dev server in pod..."
@echo " Access at: http://vite.$(K8S_HOST)"
@kubectl -n $(K8S_NS) exec deploy/web -- npm run dev
k-test: ## Run tests in k8s pod
@echo "🧪 Running tests in k8s pod..."
@kubectl -n $(K8S_NS) exec deploy/web -- php artisan test
k-seed: ## Run database seeders in k8s (usage: make k-seed SEEDER=DevSeeder)
@kubectl -n $(K8S_NS) exec deploy/web -- php artisan db:seed --class=$(SEEDER)
k-migrate-fresh: ## Fresh database with seeding in k8s pod
@echo "🔄 Running fresh migration with seeding..."
@kubectl -n $(K8S_NS) exec deploy/web -- php artisan migrate:fresh --seed
k-status: ## Show k8s environment status
@echo "📊 Status for namespace: $(K8S_NS)"
@echo ""
@kubectl -n $(K8S_NS) get pods,svc,ingress
# ==================== Production ====================
prod-build: ## Build production Docker image
docker build -t cannabrands/app:latest -f Dockerfile .
@@ -56,6 +181,43 @@ prod-artisan: ## Run artisan in production (usage: make prod-artisan CMD="migrat
prod-vite: ## Build production assets (for CI/CD)
./vendor/bin/sail npm run build
# ==================== Production Testing (Local) ====================
prod-test: ## Test production image locally (foreground)
@echo "🔨 Building and testing production image locally..."
docker-compose -f docker-compose.prod-test.yml up --build
prod-test-build: ## Build production test image (no cache)
@echo "🔨 Building production test image..."
docker-compose -f docker-compose.prod-test.yml build --no-cache --pull
prod-test-up: ## Start production test environment (background)
@echo "🚀 Starting production test environment..."
docker-compose -f docker-compose.prod-test.yml up -d
@echo ""
@echo "✅ Production test running!"
@echo " App: http://localhost:8080"
@echo " Database: localhost:5433"
@echo ""
@echo "View logs: make prod-test-logs"
prod-test-down: ## Stop production test environment
docker-compose -f docker-compose.prod-test.yml down
prod-test-logs: ## View production test logs
docker-compose -f docker-compose.prod-test.yml logs -f app
prod-test-shell: ## Open shell in production test container
docker-compose -f docker-compose.prod-test.yml exec app /bin/sh
prod-test-status: ## Check supervisor status in production test
@echo "📊 Supervisor status:"
docker-compose -f docker-compose.prod-test.yml exec app supervisorctl status
prod-test-clean: ## Clean production test environment (removes volumes)
@echo "🧹 Cleaning production test environment..."
docker-compose -f docker-compose.prod-test.yml down -v
@echo "✅ Cleaned! Run 'make prod-test' for fresh start"
# ==================== Database ====================
migrate: ## Run database migrations (Sail)
./vendor/bin/sail artisan migrate
@@ -95,16 +257,35 @@ install: ## Initial project setup
mailpit: ## Open Mailpit web UI
@open http://localhost:8025 || xdg-open http://localhost:8025 || echo "Open http://localhost:8025 in your browser"
new-worktree: ## Create new worktree (usage: make new-worktree BRANCH=feature/my-feature or make new-worktree BRANCH=feature/my-feature NEW=true)
@if [ -z "$(BRANCH)" ]; then \
echo "❌ Error: BRANCH parameter required"; \
echo ""; \
echo "Usage:"; \
echo " make new-worktree BRANCH=feature/my-feature # Checkout existing branch"; \
echo " make new-worktree BRANCH=feature/my-feature NEW=true # Create new branch"; \
exit 1; \
fi
@if [ "$(NEW)" = "true" ]; then \
./scripts/new-worktree.sh -b $(BRANCH); \
else \
./scripts/new-worktree.sh $(BRANCH); \
fi
help: ## Show this help message
@echo "\n📦 CannaBrands Docker Commands\n"
@echo "Local Development (Sail):"
@grep -E '^dev.*:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}'
@echo "\nProduction:"
@grep -E '^prod.*:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}'
@grep -E '^dev.*:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-25s\033[0m %s\n", $$1, $$2}'
@echo "\nK8s Local Development:"
@grep -E '^k-.*:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[35m%-25s\033[0m %s\n", $$1, $$2}'
@echo "\nProduction Testing (Local):"
@grep -E '^prod-test.*:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[33m%-25s\033[0m %s\n", $$1, $$2}'
@echo "\nProduction (K8s/Deployment):"
@grep -E '^prod-[^t].*:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-25s\033[0m %s\n", $$1, $$2}'
@echo "\nDatabase:"
@grep -E '^(migrate|seed).*:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}'
@grep -E '^(migrate|seed).*:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-25s\033[0m %s\n", $$1, $$2}'
@echo "\nTesting:"
@grep -E '^test.*:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}'
@grep -E '^test.*:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-25s\033[0m %s\n", $$1, $$2}'
@echo "\nUtilities:"
@grep -E '^(clean|install|mailpit).*:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}'
@grep -E '^(clean|install|mailpit).*:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-25s\033[0m %s\n", $$1, $$2}'
@echo ""

View File

@@ -1,237 +0,0 @@
# Notification & Email Policy
**Last Updated**: January 2025
**Status**: Initial Implementation (Day 15)
---
## Overview
This document outlines the notification and email strategy for the Cannabrands B2B platform. The system uses both email notifications (via Laravel Mail) and in-app notifications for real-time updates.
---
## Email Notifications
### Order Workflow Emails
| Event | Recipient | Template | Priority | Notes |
|-------|-----------|----------|----------|-------|
| **New Order Placed** | Seller (Admin) | `emails.orders.new-order` | High | Includes order summary, buyer info, items list |
| **Order Accepted** | Buyer | `emails.orders.order-accepted` | Medium | Confirms acceptance, shows estimated timeline |
| **Order Ready for Delivery** | Buyer | `emails.orders.ready-for-delivery` | High | Notifies order is packed and ready to ship |
| **Order Delivered** | Buyer | `emails.orders.order-delivered` | Medium | Delivery confirmation with thank you message |
| **Order Cancelled** | Buyer | `emails.orders.order-cancelled` | High | Cancellation notice with reason if provided |
### Invoice Workflow Emails
| Event | Recipient | Template | Priority | Notes |
|-------|-----------|----------|----------|-------|
| **Invoice Generated** | Buyer | `emails.invoices.invoice-ready` | High | Invoice ready for buyer approval after seller review |
| **Invoice Overdue (3 days)** | Buyer | `emails.invoices.payment-reminder-3day` | High | First reminder |
| **Invoice Overdue (7 days)** | Buyer | `emails.invoices.payment-reminder-7day` | High | Second reminder |
| **Invoice Overdue (14 days)** | Buyer + Seller | `emails.invoices.payment-reminder-14day` | Critical | Final reminder, copied to seller |
| **Payment Received** | Buyer | `emails.invoices.payment-received` | Low | Payment confirmation (future feature) |
### Picking Workflow Emails
| Event | Recipient | Template | Priority | Notes |
|-------|-----------|----------|----------|-------|
| **Picking Complete (100%)** | Seller | `emails.orders.picking-complete` | High | Alerts seller to review and generate invoice |
---
## In-App Notifications
### Notification Types
**For Buyers:**
- 🛒 New order confirmation
- ✅ Order accepted by seller
- 📦 Order ready for delivery
- 🚚 Order delivered
- 📄 Invoice ready for approval
- ⚠️ Payment due soon (3 days before due date)
- 🔴 Payment overdue
**For Sellers (Admin Panel):**
- 🛍️ New order received
- 📋 Picking complete - ready for review
- 💳 Payment received (future)
### Notification Bell Behavior
- **Unread Count Badge**: Shows count of unread notifications
- **Auto-refresh**: Checks for new notifications every 30 seconds
- **Mark as Read**: Clicking notification marks it as read
- **Persistence**: Notifications stored in database, not deleted after reading
- **Archive**: Users can manually dismiss notifications (future feature)
---
## Email Template Design
All emails follow the branded template pattern established in `emails.registration.verification`:
**Brand Elements:**
- Logo: `https://hub.cannabrands.com/assets/images/canna_white.png`
- Primary Color: `#014847` (teal gradient)
- Font: DM Sans
- Button Style: Teal gradient background, white text
- Footer: Cannabrands © 2025, support contact
**Template Structure:**
```blade
@component('mail::message')
# [Email Subject Line]
[Email body content - plain language, buyer/seller focused]
@component('mail::button', ['url' => $actionUrl, 'color' => 'primary'])
[Call to Action Button]
@endcomponent
[Additional information or next steps]
Thanks,<br>
The Cannabrands Team
@endcomponent
```
---
## Notification Preferences (Future)
Allow users to control notification settings:
### Email Preferences
- [ ] Order updates (placed, accepted, delivered)
- [ ] Invoice notifications (generated, due soon, overdue)
- [ ] Marketing emails (new products, promotions)
- [ ] System announcements
### In-App Preferences
- [ ] Real-time notifications
- [ ] Desktop push notifications (future)
- [ ] Sound alerts
### Frequency Settings
- [ ] Immediate (default)
- [ ] Daily digest
- [ ] Weekly summary
- [ ] Disabled (except critical)
---
## Implementation Notes
### Current Status (Day 15)
**Implemented:**
- Email infrastructure using Laravel Mail
- Notification database table and model
- In-app notification dropdown (frontend exists, needs backend)
- Brand-consistent email templates
**Not Yet Implemented:**
- Automated overdue payment reminders (requires scheduled task)
- Email preference management
- Notification archiving
- Push notifications
- SMS notifications (future consideration)
### Technical Details
**Email Service**: Mailpit (local), SMTP (production)
**Notification Storage**:
- Table: `notifications`
- Model: `App\Models\Notification`
- Polymorphic relationship to User
**Queue System**:
- Development: sync driver
- Production: database/redis queue recommended for email sending
**Rate Limiting**:
- Payment reminders: Max 1 per invoice per day
- Marketing emails: Opt-in only, max 2 per week
---
## Testing Checklist
### Email Testing
- [ ] New order email arrives at seller email
- [ ] Order acceptance email arrives at buyer email
- [ ] Invoice ready email includes correct invoice number and amount
- [ ] Overdue reminders send at correct intervals
- [ ] All emails render correctly in Gmail, Outlook, Apple Mail
- [ ] All CTA buttons link to correct pages
- [ ] Unsubscribe links work (future)
### In-App Notification Testing
- [ ] Notification bell shows unread count
- [ ] Clicking notification marks it as read
- [ ] Notification links to correct resource (order/invoice)
- [ ] Notifications auto-refresh without page reload
- [ ] Mark all as read functionality works
---
## Future Enhancements
### Phase 2 (Post-MVP)
1. **Digest Emails**: Daily/weekly summary of activity
2. **Smart Notifications**: AI-powered suggestions based on buying patterns
3. **SMS Notifications**: Critical alerts via Twilio
4. **Webhook Support**: Allow third-party integrations
5. **Notification Templates**: Customizable by company
### Phase 3 (Advanced)
1. **Multi-channel**: Email + SMS + Push + Slack
2. **Notification Analytics**: Track open rates, click-through
3. **A/B Testing**: Test email subject lines and content
4. **Scheduled Sends**: Time-zone aware delivery
5. **Rich Notifications**: Images, action buttons in notifications
---
## Refinement Areas
**To be reviewed and potentially changed:**
1. **Overdue Reminder Intervals**: Currently 3/7/14 days - should it be 1/3/7 or 5/10/15?
2. **Seller Notifications**: Should sellers get daily digest of new orders or immediate alerts?
3. **Picking Alerts**: Should lab crew get notifications when orders are accepted?
4. **Invoice Approval**: Should seller be notified when buyer approves invoice?
5. **Marketing vs Transactional**: Clear separation needed for CAN-SPAM compliance
6. **Notification Retention**: How long to keep old notifications? Auto-archive after 30 days?
7. **Critical vs Non-Critical**: Which emails should bypass "unsubscribe" (transactional only)
8. **CC Recipients**: Should account managers be CC'd on order emails?
---
## Compliance Notes
**CAN-SPAM Act Requirements:**
- ✅ Clear "From" name (Cannabrands)
- ✅ Accurate subject lines
- ✅ Physical address in footer
- ✅ Unsubscribe mechanism (for marketing only)
- ⚠️ Transactional emails exempt from unsubscribe requirement
**GDPR Considerations** (if applicable):
- User consent for marketing emails
- Right to export notification history
- Right to delete notification data
- Data retention policies
---
## Contact for Policy Changes
**Product Owner**: [To be filled]
**Technical Lead**: [To be filled]
**Last Review Date**: January 2025
**Next Review Date**: March 2025

258
PRODUCT2_INSTRUCTIONS.md Normal file
View File

@@ -0,0 +1,258 @@
# PRODUCT2 MIGRATION INSTRUCTIONS
## Context
We are migrating the OLD seller product page from `../cannabrands-hub-old` to create a new "Product2" page in the current project at `/hub`. This page will be a comprehensive, modernized version of the old seller product edit page.
## Critical Rules
1. **SELLER SIDE ONLY** - Work only with `/s/` routes (seller area)
2. **STAY IN BRANCH** - `feature/product-page-migrate` (verify before making changes)
3. **ROLLBACK READY** - All database migrations must be fully reversible
4. **DO NOT TOUCH BOM** - Leave existing BOM functionality completely as-is (we'll discuss later)
5. **SINGLE PAGE LAYOUT** - No tabs, use card-based layout with Nexus components
6. **FOLLOW OLD LAYOUT** - Modernize the old product page structure, don't reinvent
## Old Project Analysis Complete
- Old project location: `../cannabrands-hub-old`
- Old used Laravel CRM for product management
- Comprehensive field analysis done (see below)
- Old layout analyzed from vendor views
## Complete Missing Fields (from migrations analysis)
### From `products` table:
```sql
-- Metadata
product_line (text, nullable)
product_link (text, nullable) -- External URL
creatives (text, nullable) -- Marketing assets
barcode (string, nullable)
brand_display_order (integer, nullable)
-- Configuration
has_varieties (boolean, default: false)
license_id (unsignedBigInteger, nullable)
sell_multiples (boolean, default: false)
fractional_quantities (boolean, default: false)
allow_sample (boolean, default: false)
isFPR (boolean, default: false)
isSellable (boolean, default: false)
-- Case/Box Packaging
isCase (boolean, default: false)
cased_qty (integer, default: 0)
isBox (boolean, default: false)
boxed_qty (integer, default: 0)
-- Dates
launch_date (date, nullable)
-- Inventory Management
inventory_manage_pct (integer, nullable) -- 0-100%
min_order_qty (integer, nullable)
max_order_qty (integer, nullable)
low_stock_threshold (integer, nullable)
low_stock_alert_enabled (boolean, default: false)
-- Strain
strain_value (decimal 8,2, nullable)
-- Arizona Compliance
arz_total_weight (decimal 10,3, nullable)
arz_usable_mmj (decimal 10,3, nullable)
-- Descriptions
long_description (text, nullable)
ingredients (text, nullable)
effects (text, nullable)
dosage_guidelines (text, nullable)
-- Visibility
show_inventory_to_buyers (boolean, default: false)
-- Threshold Automation
decreasing_qty_threshold (integer, nullable)
decreasing_qty_action (string, nullable)
increasing_qty_threshold (integer, nullable)
increasing_qty_action (string, nullable)
-- Packaging Reference
packaging_id (foreignId, nullable)
-- Enhanced Status
status (enum: available, archived, sample, backorder, internal, unavailable)
```
### Need to create:
- `product_packaging` table (id, name, description, is_active, timestamps)
## Product2 Page Layout (Single Page, No Tabs)
### Structure:
```
HEADER (Product name, SKU, status badges, action buttons)
LEFT SIDEBAR (1/3 width):
- Product Images (main + gallery + upload)
- Quick Stats Card (cost, wholesale, MSRP, margin)
- Audit Info Card (created, modified, by user)
MAIN CONTENT (2/3 width):
Card 1: Basic Information
Card 2: Pricing & Units
Card 3: Inventory Management
Card 4: Cannabis Information
Card 5: Product Details & Content
Card 6: Advanced Settings
Card 7: Compliance & Tracking
FULL WIDTH (bottom):
Card 8: Product Varieties (if has_varieties = true)
Card 9: Lab Test Results (link to separate management)
Collapsible: Audit History
```
### Cards Detail:
**Card 1: Basic Information**
- Brand (dropdown) *
- Product Line (text)
- SKU (text) *
- Barcode (text)
- Product Name (text) *
- Type (dropdown) *
- Category (text)
- Description (textarea)
- Active toggle
- Featured toggle
**Card 2: Pricing & Units**
- Cost Price, Wholesale, MSRP, Margin (auto-calc)
- Price Unit dropdown
- Net Weight + Weight Unit
- Units Per Case
- Checkboxes: Sell in Multiples, Fractional Quantities, Sell as Case, Sell as Box
**Card 3: Inventory Management**
- On Hand, Allocated, Available, Reorder Point (display)
- Min/Max Order Qty
- Low Stock Threshold + Alert checkbox
- Show Inventory to Buyers checkbox
- Inventory Management slider (0-100%)
- Threshold Automation (decrease/increase triggers)
**Card 4: Cannabis Information**
- THC%, CBD%, THC mg, CBD mg
- Strain dropdown (with classification)
- Strain Value
- Product Packaging dropdown
- Ingredients, Effects, Dosing Guidelines (text areas)
- Arizona Compliance (Total Weight, Usable MMJ)
**Card 5: Product Details & Content**
- Short Description
- Long Description (rich text editor)
- Product Link (external URL)
- Creatives/Assets
**Card 6: Advanced Settings**
- Enable Sample Requests checkbox
- Sellable Product checkbox
- Finished Product Ready checkbox
- Status dropdown
- Display Order (within brand)
**Card 7: Compliance & Tracking**
- Metrc ID
- License dropdown
- Launch Date, Harvest Date, Package Date, Test Date
**Card 8: Product Varieties** (conditional)
- Table showing child products with name, SKU, prices, stock
- Add Variety button
**Card 9: Lab Test Results**
- Summary of latest lab test
- Link to full lab management (don't build lab CRUD yet)
## Tasks to Complete
### 1. Database Migration (with rollback)
- Create migration: `add_product2_fields_to_products_table.php`
- Add ALL missing fields listed above
- Proper indexes
- Full `down()` method for rollback
- Create `product_packaging` table migration
### 2. Routes
- File: `routes/seller.php`
- Add under existing products routes:
- `/{product}/edit2` → Product2 edit page
- Keep existing routes intact
### 3. Controller
- Create: `app/Http/Controllers/Seller/Product2Controller.php`
- Methods: edit(), update()
- Full validation for all new fields
- Business isolation checks (CRITICAL - see CLAUDE.md)
- Image upload handling
### 4. Model Updates
- Update `app/Models/Product.php` fillable array
- Add new relationships if needed (packaging)
- Add accessors/mutators as needed
### 5. Views
- Create: `resources/views/seller/products/edit2.blade.php`
- Use Nexus card components
- Single page layout (no tabs)
- Alpine.js for interactivity
- Follow structure outlined above
- Use existing DaisyUI + Nexus patterns
### 6. Nexus Components Available
From `nexus-html@3.1.0/resources/views/`:
- Cards: `card`, `card-body`, `card-title`
- Forms: `input`, `select`, `textarea`, `checkbox`, `toggle`, `label`, `fieldset`
- Layouts: Grid system with responsive columns
- File upload: FilePond integration
- Date picker: Flatpickr
- Icons: Iconify (lucide set)
## Key Files from Old Project
- Controller: `vendor/venturedrake/laravel-crm/src/Http/Controllers/ProductController.php`
- Edit View: `vendor/venturedrake/laravel-crm/resources/views/products/edit.blade.php`
- Fields Form: `vendor/venturedrake/laravel-crm/resources/views/products/partials/fields.blade.php` (1400+ lines!)
## Current Project Files
- Routes: `routes/seller.php`
- Controller: `app/Http/Controllers/Seller/ProductController.php`
- Model: `app/Models/Product.php`
- Current Edit: `resources/views/seller/products/edit.blade.php`
- Migration: `database/migrations/2025_10_07_172951_create_products_table.php`
## Important Notes from CLAUDE.md
1. **Business Isolation**: ALWAYS scope by business_id BEFORE finding by ID
- `Product::whereHas('brand', fn($q) => $q->where('business_id', $business->id))->findOrFail($id)`
2. **Route Protection**: Use middleware `['auth', 'verified', 'seller', 'approved']`
3. **No Filament**: Use DaisyUI + Blade for seller area
4. **Run tests before commit**: `php artisan test --parallel && ./vendor/bin/pint`
## Git Branch
- Current: `feature/product-page-migrate`
- DO NOT commit to develop directly
## Next Steps
1. Verify branch: `git branch` (should show feature/product-page-migrate)
2. Create migrations with full rollback capability
3. Update Product model
4. Create Product2Controller
5. Create edit2.blade.php view
6. Test thoroughly
7. Run Pint + tests
8. Commit with clear message
## Questions to Clarify Before Building
- Collapsible cards to reduce clutter? (yes/no)
- Should quantity_on_hand be editable in UI? (currently hidden)
- Which fields are absolutely required vs nice-to-have?
- SQL dump ready for real data analysis?

View File

@@ -429,15 +429,37 @@ No setup required - just works!
## 📚 Documentation
### **Getting Started**
- **[Development Guide](docs/DEVELOPMENT.md)**: Complete developer setup and workflow
- ✅ **Standard/Hybrid Flow** (PHP on host + Docker services)
- ✅ **Local Kubernetes Development** (k3d, minikube, kind, Docker Desktop K8s)
- Covers all local development approaches
- **[Docker & Sail Guide](DOCKER.md)**: Laravel Sail development environment
- ✅ **Laravel Sail Flow** (All services in Docker)
- Quick start for containerized development
### **Deployment & DevOps**
- **[Deployment Workflow](docs/DEPLOYMENT_WORKFLOW.md)**: Branching strategy, CI/CD pipeline, and deployment guide
- **[Kubernetes Deployment](docs/KUBERNETES_DEPLOYMENT.md)**: Complete Kubernetes deployment guide (for DevOps)
- **[Kubernetes Deployment](docs/KUBERNETES_DEPLOYMENT.md)**: Production Kubernetes deployment guide (for DevOps/SRE)
- Production/Staging/Dev K8s clusters
- Not for local development (see DEVELOPMENT.md for local K8s)
### **Application Reference**
- **[Setup Guide](docs/SETUP.md)**: Detailed installation and configuration
- **[API Reference](docs/API.md)**: Complete API endpoint documentation
- **[Database Schema](docs/DATABASE.md)**: Database structure and relationships
- **[Notifications](docs/NOTIFICATIONS.md)**: Notification system guide
- **[App Overview](docs/APP_OVERVIEW.md)**: Project roadmap and architecture
### **Development Flow Options Summary**
| Flow | Document | Best For |
|------|----------|----------|
| **Local PHP + Docker services** | [DEVELOPMENT.md](docs/DEVELOPMENT.md) | Daily development (fastest) |
| **Laravel Sail (all Docker)** | [DOCKER.md](DOCKER.md) | Environment consistency |
| **Local Kubernetes (k3d/minikube)** | [DEVELOPMENT.md](docs/DEVELOPMENT.md) | Testing K8s deployments |
| **Production Kubernetes** | [KUBERNETES_DEPLOYMENT.md](docs/KUBERNETES_DEPLOYMENT.md) | Production/staging clusters |
---
## 🚀 Deployment

File diff suppressed because it is too large Load Diff

View File

@@ -1,368 +0,0 @@
# Invoice Approval & Modification System - Testing Guide
## Quick Start
### 1. Generate Test Data
Run this command to create a complete test order with invoice:
```bash
php artisan test:invoice-approval
```
Or specify a specific buyer:
```bash
php artisan test:invoice-approval --buyer-email=your-buyer@example.com
```
This will:
- ✅ Create a test order with 5 random products
- ✅ Progress it through the workflow (accepted → in_progress → ready_for_invoice → invoiced)
- ✅ Generate an invoice with `approval_status = 'pending_buyer_approval'`
- ✅ Display test URLs and credentials
---
## Testing Scenarios
### Scenario 1: Approve Invoice Without Changes
**Steps:**
1. Login as buyer
2. Navigate to `/b/invoices/{invoice_id}`
3. Click **"Approve Invoice"** button
4. Confirm the action
**Expected Results:**
- ✅ Invoice `approval_status``'buyer_approved'`
- ✅ Invoice `approved_at` timestamp set
- ✅ Invoice `approved_by` = current user ID
- ✅ Order `status``'manifest_created'`
- ✅ Order `manifest_created_at` timestamp set
- ✅ Success message displayed
- ✅ Page reloads with success alert
**Database Verification:**
```sql
-- Check invoice approval
SELECT id, invoice_number, approval_status, approved_at, approved_by
FROM invoices
WHERE id = {invoice_id};
-- Check order status progression
SELECT id, order_number, status, manifest_created_at
FROM orders
WHERE id = {order_id};
-- Should be no changes recorded (direct approval)
SELECT COUNT(*) FROM order_changes WHERE order_id = {order_id};
-- Expected: 0
```
---
### Scenario 2: Reject Invoice
**Steps:**
1. Login as buyer
2. Navigate to `/b/invoices/{invoice_id}`
3. Click **"Reject Invoice"** button
4. Modal opens
5. Enter rejection reason: "Prices too high"
6. Click **"Confirm Rejection"**
**Expected Results:**
- ✅ Invoice `approval_status``'rejected'`
- ✅ Invoice `rejected_at` timestamp set
- ✅ Invoice `rejection_reason` = "Prices too high"
- ✅ Order `status``'rejected'`
- ✅ Order `rejected_at` timestamp set
- ✅ Order `rejected_reason` = "Prices too high"
- ✅ Redirect to invoices index with success message
**Database Verification:**
```sql
SELECT id, invoice_number, approval_status, rejected_at, rejection_reason
FROM invoices
WHERE id = {invoice_id};
SELECT id, order_number, status, rejected_at, rejected_reason
FROM orders
WHERE id = {order_id};
```
---
### Scenario 3: Modify Invoice - Reduce Quantity (Auto-Approved)
**Steps:**
1. Login as buyer
2. Navigate to `/b/invoices/{invoice_id}`
3. Click **"Modify Invoice"** button
4. Edit mode activates
5. Find a line item with quantity 10
6. Change quantity to **9** (10% reduction - should auto-approve)
7. Click **"Save Changes"**
**Expected Results:**
- ✅ Invoice `approval_status``'buyer_modified'`
- ✅ `order_changes` record created:
- `change_type` = 'quantity_edit'
- `old_value` = 10
- `new_value` = 9
- `status` = **'auto_approved'** (because <10% reduction)
- `negotiation_round` = 1
- `user_type` = 'buyer'
- ✅ Success message: "Changes saved successfully..."
- ✅ Page reloads
**Database Verification:**
```sql
-- Check the change was recorded
SELECT * FROM order_changes
WHERE order_id = {order_id}
ORDER BY created_at DESC
LIMIT 1;
-- Verify auto-approval
SELECT change_type, old_value, new_value, status
FROM order_changes
WHERE order_id = {order_id} AND status = 'auto_approved';
```
---
### Scenario 4: Modify Invoice - Reduce Quantity (>10%, Needs Review)
**Steps:**
1. Login as buyer
2. Navigate to `/b/invoices/{invoice_id}`
3. Click **"Modify Invoice"**
4. Change a quantity from **10 to 5** (50% reduction)
5. Click **"Save Changes"**
**Expected Results:**
- ✅ Invoice `approval_status``'buyer_modified'`
- ✅ `order_changes` record created with `status` = **'pending'** (not auto-approved)
- ✅ Seller will need to review this change
**Database Verification:**
```sql
SELECT change_type, old_value, new_value, status
FROM order_changes
WHERE order_id = {order_id} AND status = 'pending';
```
---
### Scenario 5: Delete Line Item
**Steps:**
1. Login as buyer
2. Navigate to `/b/invoices/{invoice_id}`
3. Click **"Modify Invoice"**
4. Click **"Remove"** button on a line item
5. Item shows as deleted (opacity-50, crossed out)
6. Click **"Save Changes"**
**Expected Results:**
- ✅ Invoice `approval_status``'buyer_modified'`
- ✅ `order_changes` record created:
- `change_type` = 'item_delete'
- `old_value` = {original_quantity}
- `new_value` = 0
- `status` = 'pending'
- `order_item_id` = {deleted_item_id}
**Database Verification:**
```sql
SELECT * FROM order_changes
WHERE order_id = {order_id} AND change_type = 'item_delete';
```
---
### Scenario 6: Try to Increase Quantity (Should Fail)
**Steps:**
1. Login as buyer
2. Navigate to `/b/invoices/{invoice_id}`
3. Click **"Modify Invoice"**
4. Try to change quantity from **10 to 15**
5. Input field should prevent this OR show validation error
**Expected Results:**
- ✅ Validation prevents increase
- ✅ Alert: "You can only reduce quantities, not increase them."
- ✅ Quantity resets to original value
- ✅ No change saved
---
### Scenario 7: Multiple Changes in One Modification
**Steps:**
1. Login as buyer
2. Click **"Modify Invoice"**
3. Reduce item 1 quantity from 10 → 8
4. Delete item 2 entirely
5. Reduce item 3 quantity from 12 → 10
6. Click **"Save Changes"**
**Expected Results:**
- ✅ 3 separate `order_changes` records created
- ✅ Each tracked independently
- ✅ All have same `negotiation_round` (1)
- ✅ All have same `created_at` (batch)
**Database Verification:**
```sql
SELECT id, change_type, order_item_id, old_value, new_value, status, negotiation_round
FROM order_changes
WHERE order_id = {order_id}
ORDER BY created_at DESC;
```
---
### Scenario 8: View Change History
**Steps:**
1. After making changes (Scenario 7)
2. Scroll down to **"Change History"** section
3. Review the table
**Expected Results:**
- ✅ Shows recent changes (up to 5)
- ✅ Displays: Round, Type, Product, Old Value, New Value, Changed By, Status
- ✅ Status badges: success (approved/auto-approved), warning (pending), error (rejected)
- ✅ "View All Changes" button visible
---
### Scenario 9: Cancel Edit Mode
**Steps:**
1. Login as buyer
2. Click **"Modify Invoice"**
3. Make some changes (reduce qty, delete item)
4. Click **"Cancel"** button
**Expected Results:**
- ✅ All changes discarded
- ✅ Quantities reset to original values
- ✅ Deleted items restored
- ✅ Edit mode exits
- ✅ No changes saved to database
---
### Scenario 10: Negotiation Round Tracking
**This requires seller response - will test once seller UI is complete**
1. Buyer modifies invoice (Round 1)
2. Seller counter-modifies (Round 2)
3. Buyer modifies again (Round 3)
4. Check `current_negotiation_round` field increments
**Expected:**
- ✅ Max 3 rounds allowed
- ✅ `invoice.current_negotiation_round` increments
---
## Browser Console Testing
Open browser console while testing to see:
```javascript
// Alpine.js state inspection
Alpine.store('invoice')
// Check reactive data
$data.items
$data.editMode
$data.hasChanges
```
---
## Network Tab Verification
Monitor the following API calls:
### Approve Invoice
```
POST /b/invoices/{invoice}/approve
Response: { success: true, message: "Invoice approved successfully." }
```
### Reject Invoice
```
POST /b/invoices/{invoice}/reject
Body: { reason: "..." }
Redirect: /b/invoices
```
### Modify Invoice
```
POST /b/invoices/{invoice}/modify
Body: {
items: [
{ id: 1, quantity: 8, deleted: false },
{ id: 2, quantity: 0, deleted: true }
]
}
Response: { success: true, message: "Changes saved..." }
```
---
## Edge Cases to Test
### ❌ Unauthorized Access
- Try accessing another company's invoice
- Expected: 403 Forbidden
### ❌ Already Approved Invoice
- Try modifying an invoice with `approval_status = 'buyer_approved'`
- Expected: Buttons hidden, "This invoice cannot be modified" message
### ❌ Already Rejected Invoice
- Try approving a rejected invoice
- Expected: Buttons hidden, rejection reason displayed
### ❌ Empty Changes
- Click "Modify Invoice"
- Don't change anything
- Click "Save Changes"
- Expected: "No changes detected" error
---
## Post-Testing Cleanup
Remove test data:
```sql
-- Find test orders
SELECT * FROM orders WHERE notes LIKE '%Test order for invoice approval%';
-- Delete test data (cascades to order_items, invoices, order_changes)
DELETE FROM orders WHERE notes LIKE '%Test order for invoice approval%';
```
Or keep for repeated testing!
---
## Next Steps: Seller Testing (Once UI Complete)
1. Seller receives notification of buyer modifications
2. Seller reviews changes at `/seller/invoices/{invoice}`
3. Seller can:
- Approve buyer's changes (applies them, moves to amendment_in_progress)
- Counter-modify (make own changes, increments negotiation round)
- Reject outright
This will be tested once seller review UI is implemented.

View File

@@ -1,197 +0,0 @@
# Business-Scoped Seller Routes - Test Results
**Test Date:** 2025-10-15
**Tester:** Claude Code
**Test Account:** jon@cannabrands.com
**Business Slug:** cannabrands
## Test Data Summary
- **Business:** Cannabrands (slug: `cannabrands`)
- **Brands:** 13
- **Products:** 312
- **Orders:** 0 (none yet)
- **Invoices:** 0 (none yet)
- **Drivers:** 1
- **Vehicles:** 2
---
## Route Tests
### 1. Business-Scoped Dashboard
**URL:** `http://localhost:8000/s/cannabrands/dashboard`
**Expected:** Dashboard with business context
**Status:** ⏳ Testing...
### 2. Fleet Management - Drivers
**URL:** `http://localhost:8000/s/cannabrands/fleet/drivers`
**Expected:** Drivers list page with business context
**Status:** ⏳ Testing...
### 3. Fleet Management - Vehicles
**URL:** `http://localhost:8000/s/cannabrands/fleet/vehicles`
**Expected:** Vehicles list page with business context
**Status:** ⏳ Testing...
### 4. Orders List
**URL:** `http://localhost:8000/s/cannabrands/orders`
**Expected:** Orders list (empty state expected)
**Status:** ✅ PASSED (Fixed query in OrderController)
**Notes:** Fixed relationship chain to use `whereHas('items.product.brand')`
### 5. Products List
**URL:** `http://localhost:8000/s/cannabrands/products`
**Expected:** Products list with 312 products
**Status:** ⏳ Testing...
### 6. Invoices List
**URL:** `http://localhost:8000/s/cannabrands/invoices`
**Expected:** Invoices list (empty state expected)
**Status:** ✅ PASSED
**Notes:** InvoiceController updated with business scope
### 7. Product Edit Page
**URL:** `http://localhost:8000/s/cannabrands/products/225/edit`
**Expected:** Product edit form with audit history
**Status:** ✅ PASSED
**Notes:** Enhanced UI, audit logging, checkbox handling fixed
---
## Access Control Tests
### Test 1: Business Slug Validation
**Test:** Access non-existent business slug
**URL:** `http://localhost:8000/s/invalid-slug/dashboard`
**Expected:** 404 Not Found
**Status:** ⏳ Testing...
### Test 2: Unauthorized Business Access
**Test:** User attempts to access another user's business
**Expected:** 403 Forbidden
**Status:** ⏳ Testing...
**Notes:** Requires second test account
### Test 3: Unauthenticated Access
**Test:** Access business-scoped route without login
**Expected:** Redirect to login page
**Status:** ⏳ Testing...
---
## Route Model Binding Tests
### Test 1: Business by Slug
**Binding:** `{business}` → Business model by slug
**Test URL:** `/s/cannabrands/dashboard`
**Expected:** Resolve to Business with slug 'cannabrands'
**Status:** ⏳ Testing...
### Test 2: Order by Order Number
**Binding:** `{order}` → Order model by order_number
**Test URL:** `/s/cannabrands/orders/{order_number}`
**Expected:** Resolve to Order by order_number field
**Status:** ⏳ Testing... (requires order data)
### Test 3: Product by ID
**Binding:** `{product}` → Product model by ID
**Test URL:** `/s/cannabrands/products/225/edit`
**Expected:** Resolve to Product with ID 225
**Status:** ✅ PASSED
---
## Critical Routes Requiring Approval Middleware
These routes require `approved` middleware:
- ✅ Orders: `/s/{business}/orders`
- ✅ Invoices: `/s/{business}/invoices`
- ✅ Products: `/s/{business}/products`
- ✅ Components: `/s/{business}/components`
- ✅ Customers: `/s/{business}/customers`
**Test Account Status:** jon@cannabrands.com is approved ✓
---
## Known Issues & Fixes Applied
### Issue 1: OrderController Query Error ✅ FIXED
**Error:** `Column "seller_business_id" does not exist`
**Fix:** Changed query to use correct relationship chain:
```php
->whereHas('items.product.brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})
```
**File:** `app/Http/Controllers/OrderController.php:26`
### Issue 2: Invoice Model Fillable Array ✅ FIXED
**Error:** Using deprecated `company_id` instead of `business_id`
**Fix:** Updated fillable array in Invoice model
**File:** `app/Models/Invoice.php`
### Issue 3: Invoice Routes Not Business-Scoped ✅ FIXED
**Error:** Invoice routes not accepting business parameter
**Fix:** Updated InvoiceController to accept Business parameter and verify ownership
**File:** `app/Http/Controllers/Seller/InvoiceController.php`
### Issue 4: Product Checkbox Not Unchecking ✅ FIXED
**Error:** Featured checkbox stays checked when unchecked and saved
**Fix:** Added explicit checkbox handling in ProductController:
```php
$validated['is_active'] = $request->has('is_active');
$validated['is_featured'] = $request->has('is_featured');
```
**File:** `app/Http/Controllers/Seller/ProductController.php`
### Issue 5: Alpine.js FOUC on Sidebar ✅ FIXED
**Error:** Brief flash of expanded menu items during page load
**Fix:** Added `x-cloak` directive to sidebar menu container
**File:** `resources/views/components/seller-sidebar.blade.php:35`
### Issue 6: Alpine.js FOUC on Notifications ✅ FIXED
**Error:** Brief flash of notification dropdown during page load
**Fix:** Added `x-cloak` directive to notification dropdown container
**File:** `resources/views/layouts/app-with-sidebar.blade.php:67`
---
## Manual Testing Checklist
- [ ] Login as seller (jon@cannabrands.com)
- [ ] Navigate to `/s/cannabrands/dashboard`
- [ ] Test fleet management pages (drivers, vehicles)
- [ ] Test orders page (empty state)
- [ ] Test products list and edit
- [ ] Test invoices page (empty state)
- [ ] Test product audit history
- [ ] Test checkbox toggles
- [ ] Verify sidebar menu persistence
- [ ] Verify notification dropdown works
- [ ] Test accessing invalid business slug
- [ ] Test logout and re-login flow
---
## Recommendations
1. **Create test orders** to fully test the order management workflow
2. **Add automated tests** for business-scoped routing and access control
3. **Monitor production** for any route binding issues
4. **Document** the business-scoped routing pattern for future development
5. **Consider** adding middleware to verify business ownership on all routes
---
## Summary
**Total Routes Tested:** 7
**Passed:** 4
**In Progress:** 3
**Failed:** 0
**Critical Fixes Applied:** 6
**Access Control:** ✅ Implemented via custom route model binding
**Business Scope:** ✅ All routes accept business parameter
**Database Queries:** ✅ Fixed to use correct relationship chains

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Cart Updated Event
*
* Broadcasts real-time cart count updates to authenticated users.
* Best practices:
* - Uses ShouldBroadcast for automatic broadcasting
* - Broadcasts on private channel (user-specific)
* - Includes minimal data (just count, not full cart)
* - Uses custom event name via broadcastAs()
*/
class CartUpdated implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(
public int $userId,
public int $count
) {}
/**
* Get the channels the event should broadcast on.
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
// Private channel - only authenticated user can listen
return [
new PrivateChannel('user.'.$this->userId),
];
}
/**
* The event's broadcast name.
*/
public function broadcastAs(): string
{
return 'CartUpdated';
}
/**
* Get the data to broadcast.
*
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
return [
'count' => $this->count,
];
}
}

View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Picking Progress Updated Event
*
* Broadcasts real-time picking progress updates to all workers on the same picking ticket.
* Use case: Multiple warehouse workers picking different items from the same order simultaneously.
*
* Best practices:
* - Uses ShouldBroadcast for automatic broadcasting
* - Broadcasts on private channel (order-specific)
* - Includes minimal data (item ID, picked quantity, progress)
* - Uses custom event name via broadcastAs()
*/
class PickingProgressUpdated implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* Create a new event instance.
*
* @param int $orderId The order ID
* @param int $itemId The order item ID that was updated
* @param int $pickedQty The new picked quantity for this item
* @param float $progress Overall picking progress percentage (0-100)
*/
public function __construct(
public int $orderId,
public int $itemId,
public int $pickedQty,
public float $progress
) {}
/**
* Get the channels the event should broadcast on.
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
// Private channel - only authorized users (sellers on this order) can listen
return [
new PrivateChannel('picking-ticket.'.$this->orderId),
];
}
/**
* The event's broadcast name.
*/
public function broadcastAs(): string
{
return 'PickingProgressUpdated';
}
/**
* Get the data to broadcast.
*
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
return [
'itemId' => $this->itemId,
'pickedQty' => $this->pickedQty,
'progress' => $this->progress,
];
}
}

View File

@@ -33,7 +33,10 @@ class BrandResource extends Resource
public static function getNavigationBadge(): ?string
{
return static::getModel()::count();
// Cache brand count for 60 seconds to reduce database queries on every page load
return cache()->remember('brand_count', 60, function () {
return static::getModel()::count() ?: null;
});
}
public static function form(Schema $schema): Schema

View File

@@ -18,6 +18,7 @@ class BrandsTable
public static function configure(Table $table): Table
{
return $table
->modifyQueryUsing(fn ($query) => $query->with(['business']))
->columns([
TextColumn::make('business.name')
->searchable(),

View File

@@ -0,0 +1,77 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\BusinessModuleResource\Pages\CreateBusinessModule;
use App\Filament\Resources\BusinessModuleResource\Pages\EditBusinessModule;
use App\Filament\Resources\BusinessModuleResource\Pages\ListBusinessModules;
use App\Filament\Resources\BusinessModuleResource\Pages\ViewBusinessModule;
use App\Filament\Resources\BusinessModuleResource\Schemas\BusinessModuleForm;
use App\Filament\Resources\BusinessModuleResource\Schemas\BusinessModuleInfolist;
use App\Filament\Resources\BusinessModuleResource\Tables\BusinessModulesTable;
use App\Models\BusinessModule;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use UnitEnum;
class BusinessModuleResource extends Resource
{
protected static ?string $model = BusinessModule::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedCog;
protected static UnitEnum|string|null $navigationGroup = 'System';
protected static ?int $navigationSort = 11;
protected static ?string $navigationLabel = 'Business Modules';
protected static ?string $modelLabel = 'Business Module';
public static function getNavigationBadge(): ?string
{
return cache()->remember('business_module_count', 60, function () {
return static::getModel()::active()->count() ?: null;
});
}
public static function canViewAny(): bool
{
return auth()->check() && auth()->user()->user_type === 'admin';
}
public static function form(Schema $schema): Schema
{
return BusinessModuleForm::configure($schema);
}
public static function infolist(Schema $schema): Schema
{
return BusinessModuleInfolist::configure($schema);
}
public static function table(Table $table): Table
{
return BusinessModulesTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListBusinessModules::route('/'),
'create' => CreateBusinessModule::route('/create'),
'view' => ViewBusinessModule::route('/{record}'),
'edit' => EditBusinessModule::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\BusinessModuleResource\Pages;
use App\Filament\Resources\BusinessModuleResource;
use Filament\Resources\Pages\CreateRecord;
class CreateBusinessModule extends CreateRecord
{
protected static string $resource = BusinessModuleResource::class;
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Filament\Resources\BusinessModuleResource\Pages;
use App\Filament\Resources\BusinessModuleResource;
use Filament\Actions\DeleteAction;
use Filament\Actions\ViewAction;
use Filament\Resources\Pages\EditRecord;
class EditBusinessModule extends EditRecord
{
protected static string $resource = BusinessModuleResource::class;
protected function getHeaderActions(): array
{
return [
ViewAction::make(),
DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\BusinessModuleResource\Pages;
use App\Filament\Resources\BusinessModuleResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListBusinessModules extends ListRecords
{
protected static string $resource = BusinessModuleResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\BusinessModuleResource\Pages;
use App\Filament\Resources\BusinessModuleResource;
use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord;
class ViewBusinessModule extends ViewRecord
{
protected static string $resource = BusinessModuleResource::class;
protected function getHeaderActions(): array
{
return [
EditAction::make(),
];
}
}

View File

@@ -0,0 +1,112 @@
<?php
namespace App\Filament\Resources\BusinessModuleResource\Schemas;
use App\Models\Module;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Grid;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Get;
use Filament\Schemas\Schema;
class BusinessModuleForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Section::make('Business Module Configuration')
->schema([
Grid::make(2)
->schema([
Select::make('business_id')
->relationship('business', 'name')
->searchable()
->preload()
->required()
->live()
->helperText('Select the business'),
Select::make('module_key')
->options(Module::active()->pluck('name', 'key'))
->searchable()
->required()
->live()
->helperText('Select the module to enable')
->disabled(fn (?string $operation): bool => $operation === 'edit'),
]),
Grid::make(2)
->schema([
Toggle::make('enabled')
->default(true)
->helperText('Enable or disable this module'),
Select::make('activated_by')
->relationship('activatedBy', 'name')
->searchable()
->preload()
->helperText('User who activated this module'),
]),
]),
Section::make('Pricing & Plan')
->schema([
Grid::make(2)
->schema([
TextInput::make('plan')
->maxLength(255)
->helperText('Plan name (e.g., basic, pro, enterprise)'),
TextInput::make('monthly_price')
->numeric()
->prefix('$')
->step(0.01)
->helperText('Monthly subscription price'),
]),
Grid::make(2)
->schema([
DateTimePicker::make('activated_at')
->default(now())
->helperText('When the module was activated'),
DateTimePicker::make('expires_at')
->helperText('Module expiration date (optional)'),
]),
])
->collapsible(),
Section::make('Configuration & Limits')
->schema([
KeyValue::make('config')
->label('Module Configuration')
->keyLabel('Config Key')
->valueLabel('Config Value')
->helperText('Custom configuration for this business')
->reorderable(),
KeyValue::make('limits')
->label('Usage Limits')
->keyLabel('Metric')
->valueLabel('Limit')
->helperText('Usage limits for this business (overrides defaults)')
->default(function (Get $get) {
$moduleKey = $get('module_key');
if (! $moduleKey) {
return [];
}
$module = Module::where('key', $moduleKey)->first();
return $module?->default_limits ?? [];
})
->reorderable(),
])
->collapsible(),
]);
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace App\Filament\Resources\BusinessModuleResource\Schemas;
use Filament\Infolists\Components\Grid;
use Filament\Infolists\Components\IconEntry;
use Filament\Infolists\Components\KeyValueEntry;
use Filament\Infolists\Components\Section;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Schema;
class BusinessModuleInfolist
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Section::make('Business Module Details')
->schema([
Grid::make(2)
->schema([
TextEntry::make('business.name')
->label('Business'),
TextEntry::make('module_key')
->label('Module')
->badge()
->color('info')
->formatStateUsing(fn (string $state): string => \App\Models\Module::where('key', $state)->value('name') ?? $state
),
]),
Grid::make(2)
->schema([
IconEntry::make('enabled')
->boolean(),
IconEntry::make('is_active')
->label('Active')
->getStateUsing(fn ($record) => $record->isActive())
->boolean(),
]),
]),
Section::make('Pricing & Plan')
->schema([
Grid::make(3)
->schema([
TextEntry::make('plan')
->badge()
->color('success')
->default('N/A'),
TextEntry::make('monthly_price')
->money('USD'),
TextEntry::make('activatedBy.name')
->label('Activated By')
->default('N/A'),
]),
Grid::make(2)
->schema([
TextEntry::make('activated_at')
->dateTime(),
TextEntry::make('expires_at')
->dateTime()
->color(fn ($state) => $state && $state->isPast() ? 'danger' : null)
->icon(fn ($state) => $state && $state->isPast() ? 'heroicon-o-exclamation-triangle' : null)
->default('Never'),
]),
])
->collapsible(),
Section::make('Configuration')
->schema([
KeyValueEntry::make('config')
->label('Module Configuration'),
KeyValueEntry::make('limits')
->label('Usage Limits'),
])
->collapsible(),
Section::make('Timestamps')
->schema([
Grid::make(2)
->schema([
TextEntry::make('created_at')
->dateTime(),
TextEntry::make('updated_at')
->dateTime(),
]),
])
->collapsible()
->collapsed(),
]);
}
}

View File

@@ -0,0 +1,123 @@
<?php
namespace App\Filament\Resources\BusinessModuleResource\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ViewAction;
use Filament\Support\Enums\FontWeight;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
class BusinessModulesTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('business.name')
->searchable()
->sortable()
->weight(FontWeight::Bold),
TextColumn::make('module_key')
->label('Module')
->searchable()
->sortable()
->badge()
->color('info')
->formatStateUsing(fn (string $state): string => \App\Models\Module::where('key', $state)->value('name') ?? $state
),
IconColumn::make('enabled')
->boolean()
->sortable(),
TextColumn::make('plan')
->searchable()
->badge()
->color('success')
->default('N/A'),
TextColumn::make('monthly_price')
->money('USD')
->sortable(),
TextColumn::make('activated_at')
->dateTime()
->sortable()
->toggleable(),
TextColumn::make('activatedBy.name')
->label('Activated By')
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('expires_at')
->dateTime()
->sortable()
->toggleable()
->color(fn ($state) => $state && $state->isPast() ? 'danger' : null)
->icon(fn ($state) => $state && $state->isPast() ? 'heroicon-o-exclamation-triangle' : null),
IconColumn::make('is_active')
->label('Active')
->getStateUsing(fn ($record) => $record->isActive())
->boolean(),
TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
SelectFilter::make('business_id')
->relationship('business', 'name')
->searchable()
->preload()
->label('Business'),
SelectFilter::make('module_key')
->options(\App\Models\Module::pluck('name', 'key'))
->label('Module')
->searchable(),
TernaryFilter::make('enabled')
->label('Enabled')
->boolean()
->trueLabel('Enabled only')
->falseLabel('Disabled only')
->native(false),
TernaryFilter::make('expired')
->label('Expired')
->queries(
true: fn ($query) => $query->whereNotNull('expires_at')->where('expires_at', '<=', now()),
false: fn ($query) => $query->where(function ($q) {
$q->whereNull('expires_at')->orWhere('expires_at', '>', now());
}),
)
->trueLabel('Expired only')
->falseLabel('Active only')
->native(false),
])
->actions([
ViewAction::make(),
EditAction::make(),
])
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
])
->defaultSort('created_at', 'desc');
}
}

View File

@@ -52,8 +52,10 @@ class BusinessResource extends Resource
public static function getNavigationBadge(): ?string
{
// Count businesses pending approval
return static::getModel()::where('status', 'submitted')->count() ?: null;
// Count businesses pending approval (cached for 60 seconds)
return cache()->remember('business_pending_count', 60, function () {
return static::getModel()::where('status', 'submitted')->count() ?: null;
});
}
public static function form(Schema $schema): Schema
@@ -528,6 +530,7 @@ class BusinessResource extends Resource
public static function table(Table $table): Table
{
return $table
->modifyQueryUsing(fn ($query) => $query->with(['owner', 'users']))
->columns([
TextColumn::make('name')
->label('Name')
@@ -579,7 +582,8 @@ class BusinessResource extends Resource
TextColumn::make('primary_contact')
->label('Primary Contact')
->getStateUsing(function (Business $record): ?string {
$primaryUser = $record->users()->wherePivot('is_primary', true)->first();
// Use eager-loaded relationship instead of querying
$primaryUser = $record->users->first();
if ($primaryUser) {
$name = trim($primaryUser->first_name.' '.$primaryUser->last_name);
$contactType = $primaryUser->pivot->contact_type ?? null;

View File

@@ -10,6 +10,14 @@ class ListBusinesses extends ListRecords
{
protected static string $resource = BusinessResource::class;
protected function getTableQuery(): ?\Illuminate\Database\Eloquent\Builder
{
return parent::getTableQuery()
->with(['users' => function ($query) {
$query->wherePivot('is_primary', true);
}]);
}
protected function getHeaderActions(): array
{
return [

View File

@@ -33,7 +33,10 @@ class ComponentResource extends Resource
public static function getNavigationBadge(): ?string
{
return static::getModel()::count();
// Cache component count for 60 seconds to reduce database queries on every page load
return cache()->remember('component_count', 60, function () {
return static::getModel()::count() ?: null;
});
}
public static function form(Schema $schema): Schema

View File

@@ -31,8 +31,10 @@ class InvoiceResource extends Resource
public static function getNavigationBadge(): ?string
{
// Count unpaid invoices
return static::getModel()::where('payment_status', 'unpaid')->count() ?: null;
// Cache unpaid invoice count for 60 seconds to reduce database queries on every page load
return cache()->remember('invoice_unpaid_count', 60, function () {
return static::getModel()::where('payment_status', 'unpaid')->count() ?: null;
});
}
public static function form(Schema $schema): Schema

View File

@@ -16,6 +16,7 @@ class InvoicesTable
public static function configure(Table $table): Table
{
return $table
->modifyQueryUsing(fn ($query) => $query->with(['order', 'business']))
->columns([
TextColumn::make('invoice_number')
->searchable(),

View File

@@ -0,0 +1,75 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\ModuleResource\Pages\CreateModule;
use App\Filament\Resources\ModuleResource\Pages\EditModule;
use App\Filament\Resources\ModuleResource\Pages\ListModules;
use App\Filament\Resources\ModuleResource\Pages\ViewModule;
use App\Filament\Resources\ModuleResource\Schemas\ModuleForm;
use App\Filament\Resources\ModuleResource\Schemas\ModuleInfolist;
use App\Filament\Resources\ModuleResource\Tables\ModulesTable;
use App\Models\Module;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use UnitEnum;
class ModuleResource extends Resource
{
protected static ?string $model = Module::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedPuzzlePiece;
protected static UnitEnum|string|null $navigationGroup = 'System';
protected static ?int $navigationSort = 10;
protected static ?string $navigationLabel = 'Modules';
public static function getNavigationBadge(): ?string
{
return cache()->remember('module_count', 60, function () {
return static::getModel()::active()->count() ?: null;
});
}
public static function canViewAny(): bool
{
return auth()->check() && auth()->user()->user_type === 'admin';
}
public static function form(Schema $schema): Schema
{
return ModuleForm::configure($schema);
}
public static function infolist(Schema $schema): Schema
{
return ModuleInfolist::configure($schema);
}
public static function table(Table $table): Table
{
return ModulesTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListModules::route('/'),
'create' => CreateModule::route('/create'),
'view' => ViewModule::route('/{record}'),
'edit' => EditModule::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\ModuleResource\Pages;
use App\Filament\Resources\ModuleResource;
use Filament\Resources\Pages\CreateRecord;
class CreateModule extends CreateRecord
{
protected static string $resource = ModuleResource::class;
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Filament\Resources\ModuleResource\Pages;
use App\Filament\Resources\ModuleResource;
use Filament\Actions\DeleteAction;
use Filament\Actions\ViewAction;
use Filament\Resources\Pages\EditRecord;
class EditModule extends EditRecord
{
protected static string $resource = ModuleResource::class;
protected function getHeaderActions(): array
{
return [
ViewAction::make(),
DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\ModuleResource\Pages;
use App\Filament\Resources\ModuleResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListModules extends ListRecords
{
protected static string $resource = ModuleResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\ModuleResource\Pages;
use App\Filament\Resources\ModuleResource;
use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord;
class ViewModule extends ViewRecord
{
protected static string $resource = ModuleResource::class;
protected function getHeaderActions(): array
{
return [
EditAction::make(),
];
}
}

View File

@@ -0,0 +1,115 @@
<?php
namespace App\Filament\Resources\ModuleResource\Schemas;
use Filament\Forms\Components\Fieldset;
use Filament\Forms\Components\Grid;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Schema;
class ModuleForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Section::make('Module Information')
->schema([
Grid::make(2)
->schema([
TextInput::make('key')
->required()
->unique(ignoreRecord: true)
->maxLength(255)
->helperText('Unique identifier for the module (e.g., sms_gateway)')
->disabled(fn (?string $operation): bool => $operation === 'edit'),
TextInput::make('name')
->required()
->maxLength(255),
]),
Textarea::make('description')
->columnSpanFull()
->rows(3),
Grid::make(3)
->schema([
Select::make('category')
->options([
'Communication' => 'Communication',
'Sales' => 'Sales',
'Operations' => 'Operations',
'Finance' => 'Finance',
'Marketing' => 'Marketing',
'Support' => 'Support',
'Analytics' => 'Analytics',
])
->searchable()
->preload(),
TextInput::make('icon')
->helperText('Heroicon name (e.g., heroicon-o-cube)'),
TextInput::make('sort_order')
->numeric()
->default(0)
->helperText('Lower numbers appear first'),
]),
]),
Section::make('Module Settings')
->schema([
Fieldset::make('Status & Features')
->schema([
Grid::make(2)
->schema([
Toggle::make('is_active')
->label('Active')
->default(true)
->helperText('Is this module available for use?'),
Toggle::make('is_premium')
->label('Premium Module')
->default(false)
->helperText('Requires payment or subscription'),
]),
Grid::make(2)
->schema([
Toggle::make('requires_approval')
->label('Requires Approval')
->default(false)
->helperText('Admin must approve before activation'),
Toggle::make('enabled_by_default')
->label('Enabled by Default')
->default(false)
->helperText('Automatically enabled for new businesses'),
]),
]),
]),
Section::make('Configuration & Limits')
->schema([
KeyValue::make('config')
->label('Module Configuration')
->keyLabel('Config Key')
->valueLabel('Config Value')
->helperText('JSON configuration for module features'),
KeyValue::make('default_limits')
->label('Default Limits')
->keyLabel('Metric')
->valueLabel('Limit')
->helperText('Default usage limits (e.g., sms_per_month: 1000)'),
])
->collapsible(),
]);
}
}

View File

@@ -0,0 +1,97 @@
<?php
namespace App\Filament\Resources\ModuleResource\Schemas;
use Filament\Infolists\Components\Grid;
use Filament\Infolists\Components\IconEntry;
use Filament\Infolists\Components\KeyValueEntry;
use Filament\Infolists\Components\Section;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Schema;
class ModuleInfolist
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Section::make('Module Information')
->schema([
Grid::make(2)
->schema([
TextEntry::make('name'),
TextEntry::make('key')
->badge()
->color('gray'),
]),
TextEntry::make('description')
->columnSpanFull(),
Grid::make(3)
->schema([
TextEntry::make('category')
->badge()
->color(fn (string $state): string => match ($state) {
'Communication' => 'info',
'Sales' => 'success',
'Operations' => 'warning',
'Finance' => 'danger',
default => 'gray',
}),
TextEntry::make('icon'),
TextEntry::make('sort_order')
->label('Sort Order'),
]),
]),
Section::make('Module Status')
->schema([
Grid::make(4)
->schema([
IconEntry::make('is_active')
->label('Active')
->boolean(),
IconEntry::make('is_premium')
->label('Premium')
->boolean(),
IconEntry::make('requires_approval')
->label('Requires Approval')
->boolean(),
IconEntry::make('enabled_by_default')
->label('Enabled by Default')
->boolean(),
]),
]),
Section::make('Configuration')
->schema([
KeyValueEntry::make('config')
->label('Module Configuration'),
KeyValueEntry::make('default_limits')
->label('Default Limits'),
])
->collapsible(),
Section::make('Timestamps')
->schema([
Grid::make(2)
->schema([
TextEntry::make('created_at')
->dateTime(),
TextEntry::make('updated_at')
->dateTime(),
]),
])
->collapsible()
->collapsed(),
]);
}
}

View File

@@ -0,0 +1,127 @@
<?php
namespace App\Filament\Resources\ModuleResource\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ViewAction;
use Filament\Support\Enums\FontWeight;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
class ModulesTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->searchable()
->sortable()
->weight(FontWeight::Bold),
TextColumn::make('key')
->searchable()
->sortable()
->badge()
->color('gray'),
TextColumn::make('category')
->searchable()
->sortable()
->badge()
->color(fn (string $state): string => match ($state) {
'Communication' => 'info',
'Sales' => 'success',
'Operations' => 'warning',
'Finance' => 'danger',
default => 'gray',
}),
TextColumn::make('description')
->limit(50)
->tooltip(function (TextColumn $column): ?string {
$state = $column->getState();
if (strlen($state) <= 50) {
return null;
}
return $state;
}),
IconColumn::make('is_active')
->label('Active')
->boolean()
->sortable(),
IconColumn::make('is_premium')
->label('Premium')
->boolean()
->sortable(),
IconColumn::make('requires_approval')
->label('Requires Approval')
->boolean(),
IconColumn::make('enabled_by_default')
->label('Default')
->boolean(),
TextColumn::make('sort_order')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
TernaryFilter::make('is_active')
->label('Active')
->boolean()
->trueLabel('Active only')
->falseLabel('Inactive only')
->native(false),
TernaryFilter::make('is_premium')
->label('Premium')
->boolean()
->trueLabel('Premium only')
->falseLabel('Free only')
->native(false),
SelectFilter::make('category')
->options([
'Communication' => 'Communication',
'Sales' => 'Sales',
'Operations' => 'Operations',
'Finance' => 'Finance',
'Marketing' => 'Marketing',
'Support' => 'Support',
'Analytics' => 'Analytics',
])
->native(false),
])
->actions([
ViewAction::make(),
EditAction::make(),
])
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
])
->defaultSort('sort_order');
}
}

View File

@@ -34,8 +34,10 @@ class OrderResource extends Resource
public static function getNavigationBadge(): ?string
{
// Count pending and processing orders
return static::getModel()::whereIn('status', ['pending', 'processing', 'confirmed'])->count() ?: null;
// Cache active order count for 60 seconds to reduce database queries on every page load
return cache()->remember('order_active_count', 60, function () {
return static::getModel()::whereIn('status', ['pending', 'processing', 'confirmed'])->count() ?: null;
});
}
public static function form(Schema $schema): Schema

View File

@@ -15,6 +15,7 @@ class OrdersTable
public static function configure(Table $table): Table
{
return $table
->modifyQueryUsing(fn ($query) => $query->with(['business']))
->columns([
TextColumn::make('order_number')
->label('Order #')

View File

@@ -36,7 +36,10 @@ class ProductResource extends Resource
public static function getNavigationBadge(): ?string
{
return static::getModel()::count();
// Cache product count for 60 seconds to reduce database queries on every page load
return cache()->remember('product_count', 60, function () {
return static::getModel()::count() ?: null;
});
}
public static function form(Schema $schema): Schema

View File

@@ -20,6 +20,7 @@ class ProductsTable
public static function configure(Table $table): Table
{
return $table
->modifyQueryUsing(fn ($query) => $query->with(['brand', 'strain']))
->columns([
ImageColumn::make('image_path')
->label('Image')

View File

@@ -36,8 +36,10 @@ class UserResource extends Resource
public static function getNavigationBadge(): ?string
{
// Count inactive and suspended users
return static::getModel()::whereIn('status', ['inactive', 'suspended'])->count() ?: null;
// Cache inactive/suspended user count for 60 seconds to reduce database queries on every page load
return cache()->remember('user_inactive_count', 60, function () {
return static::getModel()::whereIn('status', ['inactive', 'suspended'])->count() ?: null;
});
}
public static function form(Schema $schema): Schema
@@ -185,7 +187,8 @@ class UserResource extends Resource
TextColumn::make('business')
->label('Business')
->getStateUsing(function ($record): ?string {
$business = $record->businesses()->first();
// Use eager-loaded relationship instead of querying
$business = $record->businesses->first();
if ($business) {
$role = $business->pivot->role ?? 'Member';
@@ -235,6 +238,13 @@ class UserResource extends Resource
EditAction::make()
->label('View/Modify')
->icon('heroicon-o-pencil'),
Action::make('impersonate')
->label('Impersonate')
->icon('heroicon-o-user-circle')
->color('warning')
->visible(fn (User $record) => auth()->user()->canImpersonate() && $record->canBeImpersonated())
->url(fn (User $record) => route('admin.impersonate.perform', $record))
->openUrlInNewTab(false),
Action::make('suspend')
->label('Suspend')
->icon('heroicon-o-no-symbol')

View File

@@ -10,6 +10,12 @@ class ListUsers extends ListRecords
{
protected static string $resource = UserResource::class;
protected function getTableQuery(): ?\Illuminate\Database\Eloquent\Builder
{
return parent::getTableQuery()
->with('businesses');
}
protected function getHeaderActions(): array
{
return [

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Events\PickingProgressUpdated;
use App\Http\Controllers\Controller;
use App\Models\OrderItem;
use Illuminate\Http\JsonResponse;
@@ -18,8 +19,6 @@ class WorkorderController extends Controller
public function updatePickedQuantity(Request $request): JsonResponse
{
try {
\Log::info('Workorder update request', $request->all());
$validated = $request->validate([
'id' => 'required|integer|exists:order_items,id',
'qty' => 'required|numeric|min:0',
@@ -46,6 +45,14 @@ class WorkorderController extends Controller
$order->refresh();
$orderItem->refresh();
// Broadcast real-time update to all workers on this picking ticket
broadcast(new PickingProgressUpdated(
orderId: $order->id,
itemId: $orderItem->id,
pickedQty: (int) $orderItem->picked_qty,
progress: (float) $order->workorder_status
))->toOthers();
return response()->json([
'success' => true,
'message' => 'Picked quantity updated successfully.',
@@ -58,7 +65,6 @@ class WorkorderController extends Controller
} catch (\Exception $e) {
\Log::error('Workorder update failed', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
return response()->json([

View File

@@ -37,7 +37,8 @@ class ContactController extends Controller
->orderBy('deleted_at', 'desc')
->get();
return view('business.contacts.index', compact('business', 'contacts', 'archivedContacts'));
return view('business.contacts.index', compact('business', 'contacts', 'archivedContacts'))
->with('useToasts', true);
}
/**

View File

@@ -170,6 +170,21 @@ class LocationController extends Controller
$business->locations()->where('id', '!=', $location->id)->update(['is_primary' => false]);
}
// Prevent disabling accepts_deliveries if this is the last location that accepts deliveries
if (! $request->boolean('accepts_deliveries') && $location->accepts_deliveries) {
$otherDeliveryLocations = $business->locations()
->where('id', '!=', $location->id)
->where('accepts_deliveries', true)
->where('is_active', true)
->count();
if ($otherDeliveryLocations === 0) {
return redirect()->back()
->withInput()
->with('error', 'You must have at least one active location that accepts deliveries for customers to place orders.');
}
}
$location->update([
'name' => $validated['name'],
'slug' => Str::slug($validated['name']),

View File

@@ -19,15 +19,21 @@ class CartController extends Controller
/**
* Display the cart page or return JSON data.
*/
public function index(Request $request): View|JsonResponse
public function index(\App\Models\Business $business, Request $request): View|JsonResponse
{
$user = $request->user();
$sessionId = $request->session()->getId();
// Fetch items once - calculate totals from loaded collection
$items = $this->cartService->getCartItems($user, $sessionId);
$subtotal = $this->cartService->getSubtotal($user, $sessionId);
$tax = $this->cartService->getTax($user, $sessionId);
$total = $this->cartService->getTotal($user, $sessionId);
$subtotal = $items->sum(fn ($item) => $item->quantity * ($item->product->wholesale_price ?? 0));
// Calculate tax based on business tax rate
$taxRate = $business->getTaxRate() ?? 0.08;
$tax = $subtotal * $taxRate;
$total = $subtotal + $tax;
// Return JSON for AJAX requests
if ($request->wantsJson() || $request->ajax()) {
@@ -55,7 +61,7 @@ class CartController extends Controller
/**
* Add item to cart (Ajax).
*/
public function add(Request $request): JsonResponse
public function add(\App\Models\Business $business, Request $request): JsonResponse
{
$request->validate([
'product_id' => 'required|exists:products,id',
@@ -68,6 +74,7 @@ class CartController extends Controller
try {
$cart = $this->cartService->addItem(
$business,
$request->integer('product_id'),
$request->integer('quantity', 1),
$user,
@@ -94,7 +101,7 @@ class CartController extends Controller
/**
* Update cart item quantity (Ajax).
*/
public function update(Request $request, int $cartId): JsonResponse
public function update(\App\Models\Business $business, Request $request, int $cartId): JsonResponse
{
$request->validate([
'quantity' => 'required|integer|min:1',
@@ -104,7 +111,7 @@ class CartController extends Controller
$sessionId = $request->session()->getId();
try {
$cart = $this->cartService->updateQuantity($cartId, $request->integer('quantity'));
$cart = $this->cartService->updateQuantity($cartId, $request->integer('quantity'), $user, $sessionId);
// Ensure product is loaded for JSON response
$cart->load('product', 'brand');
@@ -133,13 +140,13 @@ class CartController extends Controller
/**
* Remove item from cart (Ajax).
*/
public function remove(Request $request, int $cartId): JsonResponse
public function remove(\App\Models\Business $business, Request $request, int $cartId): JsonResponse
{
$this->cartService->removeItem($cartId);
$user = $request->user();
$sessionId = $request->session()->getId();
$this->cartService->removeItem($cartId, $user, $sessionId);
$subtotal = $this->cartService->getSubtotal($user, $sessionId);
$tax = $this->cartService->getTax($user, $sessionId);
$total = $this->cartService->getTotal($user, $sessionId);
@@ -158,7 +165,7 @@ class CartController extends Controller
/**
* Clear entire cart.
*/
public function clear(Request $request): JsonResponse
public function clear(\App\Models\Business $business, Request $request): JsonResponse
{
$user = $request->user();
$sessionId = $request->session()->getId();
@@ -175,7 +182,7 @@ class CartController extends Controller
/**
* Get cart count (for header badge).
*/
public function count(Request $request): JsonResponse
public function count(\App\Models\Business $business, Request $request): JsonResponse
{
$user = $request->user();
$sessionId = $request->session()->getId();

View File

@@ -23,7 +23,7 @@ class CheckoutController extends Controller
/**
* Display the checkout page.
*/
public function index(Request $request): View|RedirectResponse
public function index(Business $business, Request $request): View|RedirectResponse
{
$user = $request->user();
$sessionId = $request->session()->getId();
@@ -33,7 +33,7 @@ class CheckoutController extends Controller
// Redirect if cart is empty
if ($items->isEmpty()) {
return redirect()->route('buyer.cart.index')
return redirect()->route('buyer.business.cart.index', $business)
->with('error', 'Your cart is empty. Add some products before checking out.');
}
@@ -42,9 +42,6 @@ class CheckoutController extends Controller
$tax = $this->cartService->getTax($user, $sessionId);
$total = $this->cartService->getTotal($user, $sessionId);
// Get user's business
$business = $user->businesses()->first();
// Load delivery locations (only locations that accept deliveries)
$locations = $business
? $business->locations()
@@ -78,10 +75,10 @@ class CheckoutController extends Controller
/**
* Process the order.
*/
public function process(Request $request): RedirectResponse
public function process(Business $business, Request $request): RedirectResponse
{
$request->validate([
'location_id' => 'required|exists:locations,id',
'location_id' => 'required_if:delivery_method,delivery|nullable|exists:locations,id',
'payment_terms' => 'required|in:cod,net_15,net_30,net_60,net_90',
'notes' => 'nullable|string|max:1000',
'delivery_method' => 'required|in:delivery,pickup',
@@ -99,35 +96,27 @@ class CheckoutController extends Controller
$items = $this->cartService->getCartItems($user, $sessionId);
if ($items->isEmpty()) {
return redirect()->route('buyer.cart.index')
return redirect()->route('buyer.business.cart.index', $business)
->with('error', 'Your cart is empty.');
}
// Get user's business
$business = $user->businesses()->first();
if (! $business) {
return back()->with('error', 'No business associated with your account.');
}
// Calculate due date based on payment terms
$paymentTerms = $request->input('payment_terms');
$dueDate = $this->calculateDueDate($paymentTerms);
// Calculate totals with payment term surcharge
$baseSubtotal = $this->cartService->getSubtotal($user, $sessionId);
$surchargePercent = $this->getPaymentTermSurcharge($paymentTerms);
$surchargeAmount = $baseSubtotal * ($surchargePercent / 100);
$subtotal = $baseSubtotal + $surchargeAmount;
$subtotal = $this->cartService->getSubtotal($user, $sessionId);
$surchargePercent = Order::getSurchargePercentage($paymentTerms);
$surcharge = $subtotal * ($surchargePercent / 100);
// Tax is calculated on subtotal + surcharge using business tax rate
// (0.00 if business is tax-exempt wholesale/resale with Form 5000A)
$taxRate = $business->getTaxRate();
$tax = $subtotal * $taxRate;
$total = $subtotal + $tax;
$tax = ($subtotal + $surcharge) * $taxRate;
$total = $subtotal + $surcharge + $tax;
// Create order in transaction
$order = DB::transaction(function () use ($request, $user, $business, $items, $subtotal, $tax, $total, $paymentTerms, $dueDate) {
$order = DB::transaction(function () use ($request, $user, $business, $items, $subtotal, $surcharge, $tax, $total, $paymentTerms, $dueDate) {
// Generate order number
$orderNumber = $this->generateOrderNumber();
@@ -138,6 +127,7 @@ class CheckoutController extends Controller
'user_id' => $user->id,
'location_id' => $request->input('location_id'),
'subtotal' => $subtotal,
'surcharge' => $surcharge,
'tax' => $tax,
'total' => $total,
'status' => 'new',
@@ -185,24 +175,24 @@ class CheckoutController extends Controller
$sellerNotificationService->newOrderReceived($order);
// Redirect to success page
return redirect()->route('buyer.checkout.success', ['order' => $order->order_number])
return redirect()->route('buyer.business.checkout.success', ['business' => $business->slug, 'order' => $order->order_number])
->with('success', 'Order placed successfully!');
}
/**
* Display order confirmation page.
*/
public function success(Request $request, Order $order): View|RedirectResponse
public function success(Business $business, Request $request, Order $order): View|RedirectResponse
{
// Load relationships
$order->load(['items.product', 'business', 'location']);
// Ensure user owns this order
if ($order->user_id !== $request->user()->id) {
// Ensure order belongs to this business
if (! $order->belongsToBusiness($business)) {
abort(403, 'Unauthorized');
}
return view('buyer.checkout.success', compact('order'));
return view('buyer.checkout.success', compact('order', 'business'));
}
/**
@@ -231,19 +221,4 @@ class CheckoutController extends Controller
default => now()->addDays(30),
};
}
/**
* Get payment term surcharge percentage.
*/
private function getPaymentTermSurcharge(string $paymentTerms): float
{
return match ($paymentTerms) {
'cod' => 0.0, // COD: No surcharge
'net_15' => 5.0, // Net 15: 5% surcharge
'net_30' => 10.0, // Net 30: 10% surcharge
'net_60' => 15.0, // Net 60: 15% surcharge
'net_90' => 20.0, // Net 90: 20% surcharge
default => 0.0,
};
}
}

View File

@@ -16,15 +16,11 @@ class InvoiceController extends Controller
/**
* Display a listing of the user's invoices.
*/
public function index()
public function index(\App\Models\Business $business)
{
$user = auth()->user();
$userBusinessIds = $user->businesses->pluck('id')->toArray();
$invoices = Invoice::with(['order', 'business'])
->whereHas('order', function ($query) use ($user, $userBusinessIds) {
$query->where('user_id', $user->id)
->orWhereIn('business_id', $userBusinessIds);
->whereHas('order', function ($query) use ($business) {
$query->forBusiness($business);
})
->latest()
->get();
@@ -37,16 +33,16 @@ class InvoiceController extends Controller
'overdue' => $invoices->filter(fn ($inv) => $inv->isOverdue())->count(),
];
return view('buyer.invoices.index', compact('invoices', 'stats'));
return view('buyer.invoices.index', compact('invoices', 'stats', 'business'));
}
/**
* Display the specified invoice.
*/
public function show(Invoice $invoice)
public function show(\App\Models\Business $business, Invoice $invoice)
{
// Authorization check
if (! $this->canAccessInvoice($invoice)) {
if (! $invoice->order || ! $invoice->order->belongsToBusiness($business)) {
abort(403, 'Unauthorized to view this invoice.');
}
@@ -63,15 +59,15 @@ class InvoiceController extends Controller
];
})->values();
return view('buyer.invoices.show', compact('invoice', 'invoiceItems'));
return view('buyer.invoices.show', compact('invoice', 'invoiceItems', 'business'));
}
/**
* Approve the invoice without modifications.
*/
public function approve(Invoice $invoice)
public function approve(\App\Models\Business $business, Invoice $invoice)
{
if (! $this->canAccessInvoice($invoice)) {
if (! $invoice->order || ! $invoice->order->belongsToBusiness($business)) {
abort(403, 'Unauthorized to approve this invoice.');
}
@@ -93,13 +89,13 @@ class InvoiceController extends Controller
/**
* Reject the invoice.
*/
public function reject(Request $request, Invoice $invoice)
public function reject(\App\Models\Business $business, Request $request, Invoice $invoice)
{
$request->validate([
'reason' => 'required|string|max:1000',
]);
if (! $this->canAccessInvoice($invoice)) {
if (! $invoice->order || ! $invoice->order->belongsToBusiness($business)) {
abort(403, 'Unauthorized to reject this invoice.');
}
@@ -116,7 +112,7 @@ class InvoiceController extends Controller
/**
* Modify the invoice (record buyer's changes).
*/
public function modify(Request $request, Invoice $invoice, OrderModificationService $modificationService)
public function modify(\App\Models\Business $business, Request $request, Invoice $invoice, OrderModificationService $modificationService)
{
$request->validate([
'items' => 'required|array',
@@ -125,7 +121,7 @@ class InvoiceController extends Controller
'items.*.deleted' => 'required|boolean',
]);
if (! $this->canAccessInvoice($invoice)) {
if (! $invoice->order || ! $invoice->order->belongsToBusiness($business)) {
return response()->json([
'success' => false,
'message' => 'Unauthorized to modify this invoice.',
@@ -196,9 +192,9 @@ class InvoiceController extends Controller
/**
* Download invoice PDF.
*/
public function downloadPdf(Invoice $invoice, InvoiceService $invoiceService): Response
public function downloadPdf(\App\Models\Business $business, Invoice $invoice, InvoiceService $invoiceService): Response
{
if (! $this->canAccessInvoice($invoice)) {
if (! $invoice->order || ! $invoice->order->belongsToBusiness($business)) {
abort(403, 'Unauthorized to download this invoice.');
}
@@ -215,19 +211,4 @@ class InvoiceController extends Controller
'Content-Disposition' => 'inline; filename="'.$invoice->invoice_number.'.pdf"',
]);
}
/**
* Check if current user can access the invoice.
*/
protected function canAccessInvoice(Invoice $invoice): bool
{
$user = auth()->user();
$userBusinessIds = $user->businesses->pluck('id')->toArray();
$order = $invoice->order;
return $order && (
$order->user_id === $user->id ||
in_array($order->business_id, $userBusinessIds)
);
}
}

View File

@@ -83,6 +83,8 @@ class NotificationController extends Controller
->orderBy('created_at', 'desc')
->paginate(20);
return view('buyer.notifications.index', compact('notifications'));
$business = auth()->user()->businesses->first();
return view('buyer.notifications.index', compact('notifications', 'business'));
}
}

View File

@@ -11,16 +11,13 @@ class OrderController extends Controller
/**
* Display a listing of the user's orders.
*/
public function index()
public function index(\App\Models\Business $business)
{
$user = auth()->user();
$userBusinessIds = $user->businesses->pluck('id')->toArray();
$orders = Order::with(['items', 'business', 'location'])
->where(function ($query) use ($user, $userBusinessIds) {
$query->where('user_id', $user->id)
->orWhereIn('business_id', $userBusinessIds);
})
// Only show orders for this specific business
$orders = Order::forBusiness($business)
->with(['items', 'business', 'location'])
->latest()
->get();
@@ -32,30 +29,30 @@ class OrderController extends Controller
'delivered' => $orders->where('status', 'delivered')->count(),
];
return view('buyer.orders.index', compact('orders', 'stats'));
return view('buyer.orders.index', compact('business', 'orders', 'stats'));
}
/**
* Display the specified order.
*/
public function show(Order $order)
public function show(\App\Models\Business $business, Order $order)
{
// Authorization check
if (! $this->canAccessOrder($order)) {
// Authorization check - order must belong to this business
if (! $order->belongsToBusiness($business)) {
abort(403, 'Unauthorized to view this order.');
}
$order->load(['items.product', 'business', 'location', 'user', 'invoice', 'manifest']);
return view('buyer.orders.show', compact('order'));
return view('buyer.orders.show', compact('business', 'order'));
}
/**
* Accept an order.
*/
public function accept(Order $order)
public function accept(\App\Models\Business $business, Order $order)
{
if (! $this->canAccessOrder($order)) {
if (! $order->belongsToBusiness($business)) {
abort(403, 'Unauthorized to modify this order.');
}
@@ -71,9 +68,9 @@ class OrderController extends Controller
/**
* Cancel an order (buyer-initiated).
*/
public function cancel(Order $order, Request $request)
public function cancel(\App\Models\Business $business, Order $order, Request $request)
{
if (! $this->canAccessOrder($order)) {
if (! $order->belongsToBusiness($business)) {
abort(403, 'Unauthorized to modify this order.');
}
@@ -93,9 +90,9 @@ class OrderController extends Controller
/**
* Update order fulfillment method and related information.
*/
public function updateFulfillment(Order $order, Request $request)
public function updateFulfillment(\App\Models\Business $business, Order $order, Request $request)
{
if (! $this->canAccessOrder($order)) {
if (! $order->belongsToBusiness($business)) {
abort(403, 'Unauthorized to modify this order.');
}
@@ -134,9 +131,9 @@ class OrderController extends Controller
/**
* Download manifest PDF for an order.
*/
public function downloadManifestPdf(Order $order)
public function downloadManifestPdf(\App\Models\Business $business, Order $order)
{
if (! $this->canAccessOrder($order)) {
if (! $order->belongsToBusiness($business)) {
abort(403, 'Unauthorized to access this order.');
}
@@ -159,16 +156,4 @@ class OrderController extends Controller
]
);
}
/**
* Check if current user can access the order.
*/
protected function canAccessOrder(Order $order): bool
{
$user = auth()->user();
$userBusinessIds = $user->businesses->pluck('id')->toArray();
return $order->user_id === $user->id ||
in_array($order->business_id, $userBusinessIds);
}
}

View File

@@ -17,8 +17,11 @@ class BuyerAuthController extends Controller
*/
public function profile()
{
$business = auth()->user()->businesses->first();
return view('buyer.profile', [
'user' => auth()->user(),
'business' => $business,
]);
}
}

View File

@@ -22,9 +22,12 @@ class BuyerProfileController extends Controller
: 'none',
];
$business = auth()->user()->businesses->first();
return view('buyer.profile', [
'user' => $user,
'verificationStatus' => $verificationStatus,
'business' => $business,
]);
}

View File

@@ -287,7 +287,7 @@ class BuyerSetupController extends Controller
if ($firstName) {
// Check if AP contact already exists (in case user goes back and forward in wizard)
$existingAp = Contact::where('business_id', $business->id)
$existingAp = Contact::forBusiness($business)
->where('contact_type', 'accounts_payable')
->where('first_name', $firstName)
->where('last_name', $lastName)

View File

@@ -141,7 +141,7 @@ class DashboardController extends Controller
// Get chart data for revenue visualization
$chartData = $this->getRevenueChartData($brandIds);
return view('dashboard.nexus', [
return view('seller.dashboard', [
'user' => $user,
'business' => $business,
'needsOnboarding' => $needsOnboarding,

View File

@@ -9,7 +9,9 @@ class DispensarySetupController extends Controller
public function create(Request $request, $step = 1)
{
// TODO: Implement dispensary setup wizard
return view('buyer.dispensary.setup', compact('step'));
$business = auth()->user()->businesses->first();
return view('buyer.dispensary.setup', compact('step', 'business'));
}
public function store(Request $request, $step = 1)

View File

@@ -13,7 +13,7 @@ class DriverController extends Controller
*/
public function index(\App\Models\Business $business)
{
$drivers = Driver::where('business_id', $business->id)
$drivers = Driver::forBusiness($business)
->orderBy('created_at', 'desc')
->get();
@@ -52,7 +52,7 @@ class DriverController extends Controller
public function update(\App\Models\Business $business, Request $request, Driver $driver)
{
// Ensure driver belongs to this business
if ($driver->business_id !== $business->id) {
if (! $driver->belongsToBusiness($business)) {
abort(403, 'Unauthorized action.');
}
@@ -78,7 +78,7 @@ class DriverController extends Controller
public function destroy(\App\Models\Business $business, Driver $driver)
{
// Ensure driver belongs to this business
if ($driver->business_id !== $business->id) {
if (! $driver->belongsToBusiness($business)) {
abort(403, 'Unauthorized action.');
}
@@ -95,7 +95,7 @@ class DriverController extends Controller
public function toggle(\App\Models\Business $business, Driver $driver)
{
// Ensure driver belongs to this business
if ($driver->business_id !== $business->id) {
if (! $driver->belongsToBusiness($business)) {
abort(403, 'Unauthorized action.');
}

View File

@@ -2,23 +2,24 @@
namespace App\Http\Controllers;
use App\Models\Business;
use Illuminate\Http\Request;
class FavoriteController extends Controller
{
public function index(Request $request)
public function index(Business $business, Request $request)
{
// TODO: Implement favorites index
return view('buyer.favorites.index');
return view('buyer.favorites.index', compact('business'));
}
public function add(Request $request, $product)
public function add(Business $business, Request $request, $product)
{
// TODO: Implement add to favorites
return back();
}
public function remove(Request $request, $product)
public function remove(Business $business, Request $request, $product)
{
// TODO: Implement remove from favorites
return back();

View File

@@ -78,7 +78,9 @@ class MarketplaceController extends Controller
->limit(3)
->get();
return view('buyer.marketplace.index', compact('products', 'brands', 'featuredProducts'));
$business = auth()->user()->businesses->first();
return view('buyer.marketplace.index', compact('products', 'brands', 'featuredProducts', 'business'));
}
/**
@@ -102,7 +104,9 @@ class MarketplaceController extends Controller
->orderBy('name')
->get();
return view('buyer.marketplace.brands', compact('brands'));
$business = auth()->user()->businesses->first();
return view('buyer.marketplace.brands', compact('brands', 'business'));
}
/**
@@ -110,7 +114,9 @@ class MarketplaceController extends Controller
*/
public function category($category)
{
return view('buyer.marketplace.category', compact('category'));
$business = auth()->user()->businesses->first();
return view('buyer.marketplace.category', compact('category', 'business'));
}
/**
@@ -159,7 +165,9 @@ class MarketplaceController extends Controller
->limit(4)
->get();
return view('buyer.marketplace.product', compact('product', 'relatedProducts', 'brand'));
$business = auth()->user()->businesses->first();
return view('buyer.marketplace.product', compact('product', 'relatedProducts', 'brand', 'business'));
}
/**
@@ -191,6 +199,8 @@ class MarketplaceController extends Controller
->orderBy('name')
->paginate(20);
return view('buyer.marketplace.brand', compact('brand', 'featuredProducts', 'products'));
$business = auth()->user()->businesses->first();
return view('buyer.marketplace.brand', compact('brand', 'featuredProducts', 'products', 'business'));
}
}

View File

@@ -23,8 +23,8 @@ class OrderController extends Controller
public function index(\App\Models\Business $business, Request $request): View
{
$query = Order::with(['business', 'user', 'items.product'])
->whereHas('items.product.brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
->whereHas('items.product', function ($query) use ($business) {
$query->forBusiness($business);
})
->whereIn('status', [
'new',
@@ -143,7 +143,7 @@ class OrderController extends Controller
* Mobile-friendly interface for updating picked quantities.
* Accessed via PT-XXXXX format: /s/{business}/pick/PT-A3X7K
*/
public function pick(\App\Models\Business $business, Order $pickingTicket): View
public function pick(\App\Models\Business $business, Order $pickingTicket): View|RedirectResponse
{
$order = $pickingTicket; // For clarity in blade templates
@@ -226,12 +226,12 @@ class OrderController extends Controller
$order->load(['business', 'location']);
// Load active drivers and vehicles for this business
$drivers = \App\Models\Driver::where('business_id', $business->id)
$drivers = \App\Models\Driver::forBusiness($business)
->where('is_active', true)
->orderBy('first_name')
->get();
$vehicles = \App\Models\Vehicle::where('business_id', $business->id)
$vehicles = \App\Models\Vehicle::forBusiness($business)
->where('is_active', true)
->orderBy('name')
->get();
@@ -397,12 +397,12 @@ class OrderController extends Controller
]);
// Get active drivers and vehicles for the edit modal
$drivers = \App\Models\Driver::where('business_id', $business->id)
$drivers = \App\Models\Driver::forBusiness($business)
->where('is_active', true)
->orderBy('first_name')
->get();
$vehicles = \App\Models\Vehicle::where('business_id', $business->id)
$vehicles = \App\Models\Vehicle::forBusiness($business)
->where('is_active', true)
->orderBy('name')
->get();

View File

@@ -18,7 +18,6 @@ class BrandSwitcherController extends Controller
// If brand_id is empty, clear the session (show all brands)
if (empty($brandId)) {
session()->forget('selected_brand_id');
session()->flash('success', 'Viewing all brands');
return back();
}
@@ -31,8 +30,8 @@ class BrandSwitcherController extends Controller
return back()->with('error', 'No business associated with your account');
}
$brand = Brand::where('id', $brandId)
->where('business_id', $business->id)
$brand = Brand::forBusiness($business)
->where('id', $brandId)
->first();
if (! $brand) {
@@ -41,7 +40,6 @@ class BrandSwitcherController extends Controller
// Store selected brand in session
session(['selected_brand_id' => $brand->id]);
session()->flash('success', "Switched to {$brand->name}");
return back();
}
@@ -64,8 +62,8 @@ class BrandSwitcherController extends Controller
return null;
}
return Brand::where('id', $brandId)
->where('business_id', $business->id)
return Brand::forBusiness($business)
->where('id', $brandId)
->first();
}

View File

@@ -17,7 +17,7 @@ class ComponentController extends Controller
public function index(Request $request, Business $business)
{
// Build query - components are business-scoped
$query = Component::where('business_id', $business->id)
$query = Component::forBusiness($business)
->orderBy('name', 'asc');
// Search filter
@@ -113,7 +113,7 @@ class ComponentController extends Controller
public function edit(Business $business, Component $component)
{
// Verify component belongs to this business
if ($component->business_id !== $business->id) {
if (! $component->belongsToBusiness($business)) {
abort(403, 'This component does not belong to your business');
}
@@ -126,7 +126,7 @@ class ComponentController extends Controller
public function update(Request $request, Business $business, Component $component)
{
// Verify component belongs to this business
if ($component->business_id !== $business->id) {
if (! $component->belongsToBusiness($business)) {
abort(403, 'This component does not belong to your business');
}
@@ -189,7 +189,7 @@ class ComponentController extends Controller
public function destroy(Business $business, Component $component)
{
// Verify component belongs to this business
if ($component->business_id !== $business->id) {
if (! $component->belongsToBusiness($business)) {
abort(403, 'This component does not belong to your business');
}

View File

@@ -26,9 +26,7 @@ class InvoiceController extends Controller
->get();
// Get all products from brands owned by this business with images, stock levels, and batches
$products = \App\Models\Product::whereHas('brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})
$products = \App\Models\Product::forBusiness($business)
->where('is_active', true)
->with(['brand', 'images', 'availableBatches.labs'])
->select('id', 'brand_id', 'name', 'sku', 'description', 'wholesale_price', 'msrp_price',
@@ -79,9 +77,7 @@ class InvoiceController extends Controller
});
// Get recently invoiced products (last 30 days, top 10 most common)
$recentProducts = \App\Models\Product::whereHas('brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})
$recentProducts = \App\Models\Product::forBusiness($business)
->whereHas('orderItems.order.invoice', function ($query) {
$query->where('created_at', '>=', now()->subDays(30));
})
@@ -164,8 +160,8 @@ class InvoiceController extends Controller
{
// Get invoices where orders contain items from brands under this business
$invoices = Invoice::with(['order.items.product.brand', 'business'])
->whereHas('order.items.product.brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
->whereHas('order.items.product', function ($query) use ($business) {
$query->forBusiness($business);
})
->latest()
->get();
@@ -191,7 +187,7 @@ class InvoiceController extends Controller
// Check if any of the order's items belong to brands owned by this business
$belongsToBusiness = $invoice->order->items->some(function ($item) use ($business) {
return $item->product && $item->product->brand && $item->product->brand->business_id === $business->id;
return $item->product && $item->product->belongsToBusiness($business);
});
if (! $belongsToBusiness) {
@@ -211,7 +207,7 @@ class InvoiceController extends Controller
// Check if any of the order's items belong to brands owned by this business
$belongsToBusiness = $invoice->order->items->some(function ($item) use ($business) {
return $item->product && $item->product->brand && $item->product->brand->business_id === $business->id;
return $item->product && $item->product->belongsToBusiness($business);
});
if (! $belongsToBusiness) {
@@ -288,7 +284,7 @@ class InvoiceController extends Controller
// Check if any of the order's items belong to brands owned by this business
$belongsToBusiness = $invoice->order->items->some(function ($item) use ($business) {
return $item->product && $item->product->brand && $item->product->brand->business_id === $business->id;
return $item->product && $item->product->belongsToBusiness($business);
});
if (! $belongsToBusiness) {
@@ -345,7 +341,7 @@ class InvoiceController extends Controller
// Check if any of the order's items belong to brands owned by this business
$belongsToBusiness = $invoice->order->items->some(function ($item) use ($business) {
return $item->product && $item->product->brand && $item->product->brand->business_id === $business->id;
return $item->product && $item->product->belongsToBusiness($business);
});
if (! $belongsToBusiness) {

View File

@@ -16,7 +16,7 @@ class BomController extends Controller
public function index(Request $request, Business $business, Product $product)
{
// Verify product belongs to business (through brand)
if (! $product->brand || $product->brand->business_id !== $business->id) {
if (! $product->belongsToBusiness($business)) {
abort(403, 'This product does not belong to your business.');
}
@@ -28,18 +28,16 @@ class BomController extends Controller
// Get all available components for client-side filtering
// (No server-side filters - Alpine.js handles filtering for better UX)
$availableComponents = Component::where('business_id', $business->id)
$availableComponents = Component::forBusiness($business)
->where('is_active', true)
->orderBy('name')
->get();
// Get recently used components (from other products)
$recentComponents = Component::where('business_id', $business->id)
$recentComponents = Component::forBusiness($business)
->where('is_active', true)
->whereHas('products', function ($q) use ($business) {
$q->whereHas('brand', function ($q2) use ($business) {
$q2->where('business_id', $business->id);
});
$q->forBusiness($business);
})
->orderBy('updated_at', 'desc')
->limit(8)
@@ -103,15 +101,13 @@ class BomController extends Controller
]);
// Verify product belongs to business (through brand)
if (! $product->brand || $product->brand->business_id !== $business->id) {
if (! $product->belongsToBusiness($business)) {
abort(403);
}
// Verify component belongs to business
$component = Component::findOrFail($validated['component_id']);
if ($component->business_id !== $business->id) {
abort(403, 'This component does not belong to your business.');
}
$component = Component::forBusiness($business)
->findOrFail($validated['component_id']);
// Check if already attached
if ($product->components()->where('component_id', $validated['component_id'])->exists()) {
@@ -142,7 +138,7 @@ class BomController extends Controller
]);
// Verify product belongs to business (through brand)
if (! $product->brand || $product->brand->business_id !== $business->id) {
if (! $product->belongsToBusiness($business)) {
abort(403);
}
@@ -165,7 +161,7 @@ class BomController extends Controller
public function detach(Business $business, Product $product, Component $component)
{
// Verify product belongs to business (through brand)
if (! $product->brand || $product->brand->business_id !== $business->id) {
if (! $product->belongsToBusiness($business)) {
abort(403);
}
@@ -185,7 +181,7 @@ class BomController extends Controller
]);
// Verify product belongs to business (through brand)
if (! $product->brand || $product->brand->business_id !== $business->id) {
if (! $product->belongsToBusiness($business)) {
abort(403);
}
@@ -204,7 +200,7 @@ class BomController extends Controller
public function downloadPdf(Business $business, Product $product)
{
// Verify product belongs to business (through brand)
if (! $product->brand || $product->brand->business_id !== $business->id) {
if (! $product->belongsToBusiness($business)) {
abort(403, 'This product does not belong to your business.');
}

View File

@@ -6,6 +6,10 @@ use App\Http\Controllers\Controller;
use App\Models\Brand;
use App\Models\Business;
use App\Models\Product;
use App\Models\ProductLine;
use App\Models\ProductPackaging;
use App\Models\Strain;
use App\Models\Unit;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
@@ -69,7 +73,13 @@ class ProductController extends Controller
// Get all brands for filter dropdown
$brands = $business->brands()->orderBy('name')->get();
return view('seller.products.index', compact('business', 'products', 'brands'));
// Get product lines for this business with products count
$productLines = ProductLine::where('business_id', $business->id)
->withCount('products')
->orderBy('name')
->get();
return view('seller.products.index', compact('business', 'products', 'brands', 'productLines'));
}
/**
@@ -113,9 +123,8 @@ class ProductController extends Controller
]);
// Verify brand belongs to this business
$brand = Brand::where('id', $validated['brand_id'])
->where('business_id', $business->id)
->firstOrFail();
$brand = Brand::forBusiness($business)
->findOrFail($validated['brand_id']);
// Generate slug
$validated['slug'] = Str::slug($validated['name']);
@@ -149,23 +158,101 @@ class ProductController extends Controller
*/
public function edit(Business $business, Product $product)
{
// Eager load relationships
$product->load(['brand', 'images']);
// CRITICAL BUSINESS ISOLATION: Scope by business_id BEFORE finding by ID
$product = Product::whereHas('brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})
->with(['brand', 'unit', 'strain', 'packaging', 'varieties', 'images'])
->findOrFail($product->id);
// Verify product belongs to a brand under this business
if (! $product->brand || $product->brand->business_id !== $business->id) {
abort(403, 'This product does not belong to your business');
}
// Prepare dropdown data
$brands = Brand::where('business_id', $business->id)->get();
$strains = Strain::all();
$packagings = ProductPackaging::all();
$units = Unit::all();
$productLines = ProductLine::where('business_id', $business->id)->orderBy('name')->get();
$brands = $business->brands()->orderBy('name')->get();
// Product type options (for category dropdown)
$productTypes = [
'flower' => 'Flower',
'preroll' => 'Pre-Roll',
'vape' => 'Vape',
'concentrate' => 'Concentrate',
'edible' => 'Edible',
'topical' => 'Topical',
'tincture' => 'Tincture',
'other' => 'Other',
];
// Load audits with pagination (10 per page) for the audit history tab
$audits = $product->audits()
->with('user')
->latest()
->paginate(10);
// Status options
$statusOptions = [
'active' => 'Active',
'inactive' => 'Inactive',
'discontinued' => 'Discontinued',
];
return view('seller.products.edit', compact('business', 'product', 'brands', 'audits'));
return view('seller.products.edit', compact(
'business',
'product',
'brands',
'strains',
'packagings',
'units',
'productLines',
'productTypes',
'statusOptions'
));
}
/**
* Show the form for editing the specified product (edit1 - top header layout)
*/
public function edit1(Business $business, Product $product)
{
// CRITICAL BUSINESS ISOLATION: Scope by business_id BEFORE finding by ID
$product = Product::whereHas('brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})
->with(['brand', 'unit', 'strain', 'packaging', 'varieties', 'images'])
->findOrFail($product->id);
// Prepare dropdown data
$brands = Brand::where('business_id', $business->id)->get();
$strains = Strain::all();
$packagings = ProductPackaging::all();
$units = Unit::all();
$productLines = ProductLine::where('business_id', $business->id)->orderBy('name')->get();
// Product type options (for category dropdown)
$productTypes = [
'flower' => 'Flower',
'preroll' => 'Pre-Roll',
'vape' => 'Vape',
'concentrate' => 'Concentrate',
'edible' => 'Edible',
'topical' => 'Topical',
'tincture' => 'Tincture',
'other' => 'Other',
];
// Status options
$statusOptions = [
'active' => 'Active',
'inactive' => 'Inactive',
'discontinued' => 'Discontinued',
];
return view('seller.products.edit1', compact(
'business',
'product',
'brands',
'strains',
'packagings',
'units',
'productLines',
'productTypes',
'statusOptions'
));
}
/**
@@ -173,62 +260,150 @@ class ProductController extends Controller
*/
public function update(Request $request, Business $business, Product $product)
{
// Eager load brand relationship
$product->load('brand');
// Verify product belongs to a brand under this business
if (! $product->brand || $product->brand->business_id !== $business->id) {
abort(403, 'This product does not belong to your business');
}
// Comprehensive validation
$validated = $request->validate([
// Basic Information
'brand_id' => 'required|exists:brands,id',
'name' => 'required|string|max:255',
'sku' => 'required|string|max:100|unique:products,sku,'.$product->id,
'description' => 'nullable|string',
'type' => 'required|string|in:flower,pre-roll,concentrate,edible,beverage,topical,tincture,vaporizer,other',
'category' => 'nullable|string|max:100',
'wholesale_price' => 'required|numeric|min:0',
'price_unit' => 'required|string|in:each,gram,oz,lb,kg,ml,l',
'sku' => 'required|string|max:100',
'barcode' => 'nullable|string|max:100',
'type' => 'nullable|string',
'product_line_id' => 'nullable|exists:product_lines,id',
'unit_id' => 'required|exists:units,id',
'sell_multiples' => 'nullable|boolean',
'fractional_quantities' => 'nullable|boolean',
'allow_sample' => 'nullable|boolean',
'is_active' => 'nullable|boolean',
'is_featured' => 'nullable|boolean',
// Inventory - now includes threshold type
'status' => 'required|string',
'launch_date' => 'nullable|date',
'quantity_on_hand' => 'nullable|integer|min:0',
'quantity_allocated' => 'nullable|integer|min:0',
'sync_bamboo' => 'nullable|boolean',
'low_stock_threshold' => 'nullable|numeric|min:0',
'low_stock_threshold_type' => 'nullable|string|in:qty,percent',
'low_stock_alert_enabled' => 'nullable|boolean',
'is_assembly' => 'nullable|boolean',
'show_inventory_to_buyers' => 'nullable|boolean',
'packaging_id' => 'nullable|exists:product_packagings,id',
// Pricing & Units
'cost_per_unit' => 'nullable|numeric|min:0',
'wholesale_price' => 'nullable|numeric|min:0',
'msrp' => 'nullable|numeric|min:0',
'net_weight' => 'nullable|numeric|min:0',
'weight_unit' => 'nullable|string|in:g,oz,lb,kg,ml,l',
'units_per_case' => 'nullable|integer|min:1',
'weight_unit' => 'nullable|string|max:20',
'units_per_case' => 'nullable|integer|min:0',
'cased_qty' => 'nullable|integer|min:0',
'boxed_qty' => 'nullable|integer|min:0',
'min_order_qty' => 'nullable|integer|min:0',
'max_order_qty' => 'nullable|integer|min:0',
'is_case' => 'nullable|boolean',
'is_box' => 'nullable|boolean',
'has_varieties' => 'nullable|boolean',
// Cannabis Information
'thc_percentage' => 'nullable|numeric|min:0|max:100',
'cbd_percentage' => 'nullable|numeric|min:0|max:100',
'is_active' => 'boolean',
'is_featured' => 'boolean',
'strain_id' => 'nullable|exists:strains,id',
'thc_content_mg' => 'nullable|numeric|min:0',
'cbd_content_mg' => 'nullable|numeric|min:0',
'strain_value' => 'nullable|numeric|min:0',
'ingredients' => 'nullable|string',
'effects' => 'nullable|string',
'dosage_guidelines' => 'nullable|string',
// Arizona Compliance
'arz_total_weight' => 'nullable|numeric|min:0',
'arz_usable_mmj' => 'nullable|numeric|min:0',
'metrc_id' => 'nullable|string|max:255',
// Compliance & Tracking
'license_number' => 'nullable|string|max:255',
'harvest_date' => 'nullable|date',
'package_date' => 'nullable|date',
'test_date' => 'nullable|date',
// Product Details
'description' => 'nullable|string|max:100',
'long_description' => 'nullable|string',
'product_link' => 'nullable|url|max:255',
'creatives_json' => 'nullable|json',
// Advanced Settings
'is_sellable' => 'nullable|boolean',
'is_fpr' => 'nullable|boolean',
'is_raw_material' => 'nullable|boolean',
'brand_display_order' => 'nullable|integer|min:0',
'parent_product_id' => 'nullable|exists:products,id',
'category' => 'nullable|string|max:100',
]);
// Verify new brand belongs to this business
// Convert checkboxes to boolean
$validated['is_active'] = $request->has('is_active');
$validated['is_featured'] = $request->has('is_featured');
$validated['sell_multiples'] = $request->has('sell_multiples');
$validated['fractional_quantities'] = $request->has('fractional_quantities');
$validated['allow_sample'] = $request->has('allow_sample');
$validated['is_case'] = $request->has('is_case');
$validated['is_box'] = $request->has('is_box');
$validated['has_varieties'] = $request->has('has_varieties');
$validated['sync_bamboo'] = $request->has('sync_bamboo');
$validated['low_stock_alert_enabled'] = $request->has('low_stock_alert_enabled');
$validated['is_assembly'] = $request->has('is_assembly');
$validated['show_inventory_to_buyers'] = $request->has('show_inventory_to_buyers');
$validated['is_sellable'] = $request->has('is_sellable');
$validated['is_fpr'] = $request->has('is_fpr');
$validated['is_raw_material'] = $request->has('is_raw_material');
// Store creatives JSON
if (isset($validated['creatives_json'])) {
$validated['creatives'] = $validated['creatives_json'];
unset($validated['creatives_json']);
}
// CRITICAL BUSINESS ISOLATION: Verify brand belongs to the business
$brand = Brand::where('id', $validated['brand_id'])
->where('business_id', $business->id)
->firstOrFail();
// Update slug if name changed
if ($validated['name'] !== $product->name) {
$validated['slug'] = Str::slug($validated['name']);
}
// CRITICAL BUSINESS ISOLATION: Ensure the product belongs to this business through brand relationship
$product = Product::whereHas('brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})
->findOrFail($product->id);
// Handle checkbox fields - set to false if not present in request
$validated['is_active'] = $request->has('is_active');
$validated['is_featured'] = $request->has('is_featured');
// BUSINESS RULE: Only one active product per brand
if ($request->has('is_active') && $request->boolean('is_active')) {
$existingActiveProduct = Product::where('brand_id', $validated['brand_id'])
->where('is_active', true)
->where('id', '!=', $product->id)
->first();
if ($existingActiveProduct) {
// Check if user wants to force-activate this product
if ($request->has('force_activate') && $request->boolean('force_activate')) {
// Deactivate the existing active product
$existingActiveProduct->update(['is_active' => false]);
} else {
// Show error with option to force activate
return redirect()
->back()
->withInput()
->with('existing_active_product', $existingActiveProduct)
->withErrors(['is_active' => "Only one product can be active per brand at a time. '{$existingActiveProduct->name}' (SKU: {$existingActiveProduct->sku}) is currently active."]);
}
}
}
// Update product
$product->update($validated);
// Handle new image uploads if present
if ($request->hasFile('images')) {
foreach ($request->file('images') as $index => $image) {
$path = $image->store('products', 'public');
$product->images()->create([
'path' => $path,
'type' => 'product',
'is_primary' => $product->images()->count() === 0 && $index === 0,
]);
}
}
return back()->with('success', "Product '{$product->name}' updated successfully!");
return redirect()
->route('seller.business.products.edit', [$business->slug, $product->id])
->with('success', 'Product updated successfully!');
}
/**
@@ -236,11 +411,8 @@ class ProductController extends Controller
*/
public function destroy(Business $business, Product $product)
{
// Eager load brand relationship
$product->load('brand');
// Verify product belongs to a brand under this business
if (! $product->brand || $product->brand->business_id !== $business->id) {
// Verify product belongs to this business
if (! $product->belongsToBusiness($business)) {
abort(403, 'This product does not belong to your business');
}

View File

@@ -0,0 +1,166 @@
<?php
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Product;
use App\Models\ProductImage;
use App\Traits\FileStorageHelper;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class ProductImageController extends Controller
{
use FileStorageHelper;
/**
* Upload a new product image
*/
public function upload(Request $request, Business $business, Product $product)
{
// CRITICAL: Ensure product belongs to this business through brand
$product = Product::whereHas('brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})->findOrFail($product->id);
// Validate image
$request->validate([
'image' => 'required|image|mimes:jpeg,jpg,png|max:2048|dimensions:min_width=750,min_height=384', // 2MB max, 750x384 min
]);
// Check if product already has 6 images
if ($product->images()->count() >= 6) {
return response()->json([
'success' => false,
'message' => 'Maximum of 6 images allowed per product',
], 422);
}
// Store the image using trait method
$path = $this->storeFile($request->file('image'), 'products');
// Determine if this should be the primary image (first one)
$isPrimary = $product->images()->count() === 0;
// If setting as primary, unset other primary images
if ($isPrimary) {
$product->images()->update(['is_primary' => false]);
}
// Create the image record
$image = $product->images()->create([
'path' => $path,
'is_primary' => $isPrimary,
'sort_order' => $product->images()->max('sort_order') + 1,
]);
return response()->json([
'success' => true,
'image' => [
'id' => $image->id,
'path' => $image->path,
'is_primary' => $image->is_primary,
],
]);
}
/**
* Delete a product image
*/
public function delete(Business $business, Product $product, ProductImage $image)
{
// CRITICAL: Ensure product belongs to this business through brand
$product = Product::whereHas('brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})->findOrFail($product->id);
// Ensure image belongs to this product
if ($image->product_id !== $product->id) {
return response()->json([
'success' => false,
'message' => 'Image not found',
], 404);
}
// Delete the file from storage using trait method
$this->deleteFile($image->path);
// If deleting primary image, set next image as primary
if ($image->is_primary) {
$nextImage = $product->images()
->where('id', '!=', $image->id)
->orderBy('sort_order')
->first();
if ($nextImage) {
$nextImage->update(['is_primary' => true]);
}
}
$image->delete();
return response()->json(['success' => true]);
}
/**
* Reorder product images
*/
public function reorder(Request $request, Business $business, Product $product)
{
// CRITICAL: Ensure product belongs to this business through brand
$product = Product::whereHas('brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})->findOrFail($product->id);
$request->validate([
'order' => 'required|array',
'order.*' => 'required|integer|exists:product_images,id',
]);
$order = $request->input('order');
// Update sort order and set first image as primary
foreach ($order as $index => $imageId) {
$image = ProductImage::where('id', $imageId)
->where('product_id', $product->id)
->first();
if ($image) {
$image->update([
'sort_order' => $index,
'is_primary' => $index === 0, // First image is primary
]);
}
}
return response()->json(['success' => true]);
}
/**
* Set an image as primary
*/
public function setPrimary(Business $business, Product $product, ProductImage $image)
{
// CRITICAL: Ensure product belongs to this business through brand
$product = Product::whereHas('brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})->findOrFail($product->id);
// Ensure image belongs to this product
if ($image->product_id !== $product->id) {
return response()->json([
'success' => false,
'message' => 'Image not found',
], 404);
}
// Unset all primary flags for this product
$product->images()->update(['is_primary' => false]);
// Set this image as primary
$image->update(['is_primary' => true]);
return response()->json(['success' => true]);
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\ProductLine;
use Illuminate\Http\Request;
class ProductLineController extends Controller
{
/**
* Store a newly created resource in storage.
*/
public function store(Request $request, Business $business)
{
$request->validate([
'name' => 'required|string|max:255|unique:product_lines,name,NULL,id,business_id,'.$business->id,
]);
ProductLine::create([
'business_id' => $business->id,
'name' => $request->name,
]);
return redirect()
->route('seller.business.products.index', $business->slug)
->with('success', 'Product line created successfully.');
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, Business $business, ProductLine $productLine)
{
// Ensure business isolation
if ($productLine->business_id !== $business->id) {
abort(404);
}
$request->validate([
'name' => 'required|string|max:255|unique:product_lines,name,'.$productLine->id.',id,business_id,'.$business->id,
]);
$productLine->update([
'name' => $request->name,
]);
return redirect()
->route('seller.business.products.index', $business->slug)
->with('success', 'Product line updated successfully.');
}
/**
* Remove the specified resource from storage.
*/
public function destroy(Business $business, ProductLine $productLine)
{
// Ensure business isolation
if ($productLine->business_id !== $business->id) {
abort(404);
}
$productLine->delete();
return redirect()
->route('seller.business.products.index', $business->slug)
->with('success', 'Product line deleted successfully.');
}
}

View File

@@ -0,0 +1,118 @@
<?php
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Models\Business;
use Illuminate\Http\Request;
class SettingsController extends Controller
{
/**
* Display the company information settings page.
*/
public function companyInformation(Business $business)
{
return view('seller.settings.company-information', compact('business'));
}
/**
* Update the company information.
*/
public function updateCompanyInformation(Business $business, Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'dba_name' => 'nullable|string|max:255',
'description' => 'nullable|string|max:1000',
'business_type' => 'nullable|string',
'tin_ein' => 'nullable|string|max:20',
'license_number' => 'nullable|string|max:255',
'license_type' => 'nullable|string',
'physical_address' => 'nullable|string|max:255',
'physical_city' => 'nullable|string|max:100',
'physical_state' => 'nullable|string|max:2',
'physical_zipcode' => 'nullable|string|max:10',
'business_phone' => 'nullable|string|max:20',
'business_email' => 'nullable|email|max:255',
]);
$business->update($validated);
return redirect()
->route('seller.business.settings.company-information', $business->slug)
->with('success', 'Company information updated successfully!');
}
/**
* Display the users management settings page.
*/
public function users(Business $business)
{
return view('seller.settings.users', compact('business'));
}
/**
* Display the order settings page.
*/
public function orders(Business $business)
{
return view('seller.settings.orders', compact('business'));
}
/**
* Display the brands management page.
*/
public function brands(Business $business)
{
return view('seller.settings.brands', compact('business'));
}
/**
* Display the payment settings page.
*/
public function payments(Business $business)
{
return view('seller.settings.payments', compact('business'));
}
/**
* Display the invoice settings page.
*/
public function invoices(Business $business)
{
return view('seller.settings.invoices', compact('business'));
}
/**
* Display the manage licenses page.
*/
public function manageLicenses(Business $business)
{
return view('seller.settings.manage-licenses', compact('business'));
}
/**
* Display the plans and billing page.
*/
public function plansAndBilling(Business $business)
{
return view('seller.settings.plans-and-billing', compact('business'));
}
/**
* Display the notification preferences page.
*/
public function notifications(Business $business)
{
return view('seller.settings.notifications', compact('business'));
}
/**
* Display the report settings page.
*/
public function reports(Business $business)
{
return view('seller.settings.reports', compact('business'));
}
}

View File

@@ -51,16 +51,22 @@ class ShopController extends Controller
'taxes' => 731.25,
];
return view('buyer.shop.index', compact('featuredBrand', 'featuredProducts', 'promos', 'cart'));
$business = auth()->user()->businesses->first();
return view('buyer.shop.index', compact('featuredBrand', 'featuredProducts', 'promos', 'cart', 'business'));
}
public function brand($brand)
{
return view('buyer.shop.brand', compact('brand'));
$business = auth()->user()->businesses->first();
return view('buyer.shop.brand', compact('brand', 'business'));
}
public function product($product)
{
return view('buyer.shop.product', compact('product'));
$business = auth()->user()->businesses->first();
return view('buyer.shop.product', compact('product', 'business'));
}
}

View File

@@ -13,7 +13,7 @@ class VehicleController extends Controller
*/
public function index(\App\Models\Business $business)
{
$vehicles = Vehicle::where('business_id', $business->id)
$vehicles = Vehicle::forBusiness($business)
->orderBy('created_at', 'desc')
->get();
@@ -53,7 +53,7 @@ class VehicleController extends Controller
public function update(\App\Models\Business $business, Request $request, Vehicle $vehicle)
{
// Ensure vehicle belongs to this business
if ($vehicle->business_id !== $business->id) {
if (! $vehicle->belongsToBusiness($business)) {
abort(403, 'Unauthorized action.');
}
@@ -80,7 +80,7 @@ class VehicleController extends Controller
public function destroy(\App\Models\Business $business, Vehicle $vehicle)
{
// Ensure vehicle belongs to this business
if ($vehicle->business_id !== $business->id) {
if (! $vehicle->belongsToBusiness($business)) {
abort(403, 'Unauthorized action.');
}
@@ -97,7 +97,7 @@ class VehicleController extends Controller
public function toggle(\App\Models\Business $business, Vehicle $vehicle)
{
// Ensure vehicle belongs to this business
if ($vehicle->business_id !== $business->id) {
if (! $vehicle->belongsToBusiness($business)) {
abort(403, 'Unauthorized action.');
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class LogRequestTiming
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$start = microtime(true);
// Enable query logging
\DB::enableQueryLog();
$response = $next($request);
$duration = (microtime(true) - $start) * 1000; // Convert to milliseconds
$queries = \DB::getQueryLog();
$queryCount = count($queries);
$queryTime = collect($queries)->sum('time');
// Log if request took more than 1 second
if ($duration > 1000) {
\Log::warning('Slow Request Detected', [
'url' => $request->fullUrl(),
'method' => $request->method(),
'duration_ms' => round($duration, 2),
'query_count' => $queryCount,
'query_time_ms' => round($queryTime, 2),
'memory_mb' => round(memory_get_peak_usage(true) / 1024 / 1024, 2),
]);
// Log slowest queries
$slowQueries = collect($queries)
->sortByDesc('time')
->take(5)
->map(fn ($q) => [
'time_ms' => $q['time'],
'query' => substr($q['query'], 0, 200),
]);
\Log::info('Top 5 Slowest Queries', $slowQueries->toArray());
}
return $response;
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Models;
use App\Traits\BelongsToBusinessViaProduct;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -10,7 +11,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class Batch extends Model
{
use HasFactory, SoftDeletes;
use BelongsToBusinessViaProduct, HasFactory, SoftDeletes;
protected $fillable = [
'product_id',

View File

@@ -2,6 +2,7 @@
namespace App\Models;
use App\Traits\BelongsToBusinessDirectly;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -11,7 +12,7 @@ use Str;
class Brand extends Model
{
use HasFactory, SoftDeletes;
use BelongsToBusinessDirectly, HasFactory, SoftDeletes;
// Product Categories that can be organized under brands
public const PRODUCT_CATEGORIES = [
@@ -33,56 +34,36 @@ class Brand extends Model
// Brand Identity
'name',
'slug',
'sku_prefix', // SKU prefix for products
'description',
'story', // Brand story/background
'logo_path',
'website',
'instagram',
'facebook',
// Product Organization
'product_categories', // JSON: array of categories this brand covers
'target_market', // recreational, medical, both
'price_tier', // premium, mid, value
'sku_prefix', // SKU prefix for product identification (e.g., 'TB' for Thunder Bud)
// Brand Settings
'is_active',
'is_featured', // Featured brand for marketing
'is_private_label', // Private label for specific retailers
'minimum_order_quantity',
'lead_time_days',
// Marketing & Display
'brand_colors', // JSON: hex color codes for theming
'tagline',
'marketing_materials', // JSON: array of marketing asset URLs
'certifications', // JSON: organic, lab-tested, etc.
// Business Rules
'exclusive_to_business', // Only this business can sell this brand
'requires_approval', // Retailers need approval to carry
'geographic_restrictions', // JSON: states/regions where available
// Branding Assets
'logo_path',
'website_url',
'colors', // JSON: hex color codes for theming
// Internal
'notes',
'created_by',
'sort_order', // For display ordering
// Social Media
'instagram_handle',
'facebook_url',
'twitter_handle',
// Display Settings
'is_active',
'is_public', // Show on public site
'is_featured',
'sort_order',
// SEO
'meta_title',
'meta_description',
];
protected $casts = [
'product_categories' => 'array',
'brand_colors' => 'array',
'marketing_materials' => 'array',
'certifications' => 'array',
'geographic_restrictions' => 'array',
'colors' => 'array',
'is_active' => 'boolean',
'is_public' => 'boolean',
'is_featured' => 'boolean',
'is_private_label' => 'boolean',
'exclusive_to_business' => 'boolean',
'requires_approval' => 'boolean',
'minimum_order_quantity' => 'integer',
'lead_time_days' => 'integer',
'sort_order' => 'integer',
];
@@ -94,7 +75,7 @@ class Brand extends Model
*/
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
return $this->belongsTo(Business::class, 'business_id');
}
/**
@@ -105,14 +86,6 @@ class Brand extends Model
return $this->hasMany(Product::class);
}
/**
* User who created this brand
*/
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
// Scopes
public function scopeActive($query)
{
@@ -124,32 +97,9 @@ class Brand extends Model
return $query->where('is_featured', true);
}
public function scopeForBusiness($query, int $businessId)
{
return $query->where('business_id', $businessId);
}
public function scopeByCategory($query, string $category)
{
return $query->whereJsonContains('product_categories', $category);
}
public function scopeByPriceTier($query, string $tier)
{
return $query->where('price_tier', $tier);
}
public function scopeAvailableInState($query, string $state)
{
return $query->where(function ($q) use ($state) {
$q->whereNull('geographic_restrictions')
->orWhereJsonContains('geographic_restrictions', $state);
});
}
public function scopePublic($query)
{
return $query->where('is_private_label', false);
return $query->where('is_public', true);
}
// Helper Methods
@@ -174,31 +124,6 @@ class Brand extends Model
return $this->business;
}
/**
* Check if brand offers specific product category
*/
public function offersCategory(string $category): bool
{
return in_array($category, $this->product_categories ?? []);
}
/**
* Check if brand is available in specific state
*/
public function isAvailableInState(string $state): bool
{
// If no restrictions, available everywhere the business operates
if (empty($this->geographic_restrictions)) {
return $this->business->getActiveLicenses()
->filter(function ($license) use ($state) {
return $license->location &&
$license->location->state === $state;
})->isNotEmpty();
}
return in_array($state, $this->geographic_restrictions);
}
/**
* Get compliance status from parent Business
*/
@@ -212,7 +137,7 @@ class Brand extends Model
*/
public function getPrimaryColor(): string
{
$colors = $this->brand_colors ?? [];
$colors = $this->colors ?? [];
return $colors['primary'] ?? '#000000';
}
@@ -237,32 +162,6 @@ class Brand extends Model
->get();
}
/**
* Check if retailer can carry this brand
*/
public function canBeCarriedBy(Business $retailer): bool
{
// Must be an active buyer
if (! $retailer->isBuyer() || ! $retailer->is_active) {
return false;
}
// Check exclusive restrictions
if ($this->exclusive_to_business && $this->business_id !== $retailer->id) {
return false;
}
// Check geographic restrictions
$retailerStates = $retailer->locations()->pluck('state')->unique();
foreach ($retailerStates as $state) {
if ($this->isAvailableInState($state)) {
return true;
}
}
return false;
}
/**
* Get route key (slug for URLs)
*/

View File

@@ -2,6 +2,7 @@
namespace App\Models;
use App\Traits\HasModules;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -16,7 +17,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
class Business extends Model implements AuditableContract
{
use Auditable, HasFactory, HasUuids, SoftDeletes;
use Auditable, HasFactory, HasModules, HasUuids, SoftDeletes;
/**
* Get the columns that should receive a unique identifier.
@@ -191,6 +192,7 @@ class Business extends Model implements AuditableContract
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class, 'business_user')
->using(BusinessUser::class)
->withPivot('contact_type', 'is_primary', 'permissions')
->withTimestamps();
}

View File

@@ -0,0 +1,158 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class BusinessModule extends Model
{
use HasFactory;
protected $fillable = [
'business_id',
'module_key',
'enabled',
'config',
'limits',
'plan',
'monthly_price',
'activated_at',
'activated_by',
'expires_at',
];
protected $casts = [
'enabled' => 'boolean',
'config' => 'array',
'limits' => 'array',
'monthly_price' => 'decimal:2',
'activated_at' => 'datetime',
'expires_at' => 'datetime',
];
/**
* Get the business that owns the module.
*/
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
/**
* Get the module definition.
*/
public function module(): BelongsTo
{
return $this->belongsTo(Module::class, 'module_key', 'key');
}
/**
* Get the user who activated this module.
*/
public function activatedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'activated_by');
}
/**
* Get the usage records for this module.
*/
public function usageRecords(): HasMany
{
return $this->hasMany(BusinessModuleUsage::class, 'business_id', 'business_id')
->where('module_key', $this->module_key);
}
/**
* Scope a query to only include enabled modules.
*/
public function scopeEnabled(Builder $query): Builder
{
return $query->where('enabled', true);
}
/**
* Scope a query to only include active (enabled and not expired) modules.
*/
public function scopeActive(Builder $query): Builder
{
return $query->where('enabled', true)
->where(function ($q) {
$q->whereNull('expires_at')
->orWhere('expires_at', '>', now());
});
}
/**
* Scope a query to only include expired modules.
*/
public function scopeExpired(Builder $query): Builder
{
return $query->whereNotNull('expires_at')
->where('expires_at', '<=', now());
}
/**
* Check if the module is active (enabled and not expired).
*/
public function isActive(): bool
{
return $this->enabled && ! $this->isExpired();
}
/**
* Check if the module is expired.
*/
public function isExpired(): bool
{
return $this->expires_at !== null && $this->expires_at->isPast();
}
/**
* Check if the module is enabled.
*/
public function isEnabled(): bool
{
return $this->enabled;
}
/**
* Get a configuration value.
*/
public function getConfigValue(string $key, mixed $default = null): mixed
{
return data_get($this->config, $key, $default);
}
/**
* Get a limit value.
*/
public function getLimit(string $metric, ?int $default = null): ?int
{
return data_get($this->limits, $metric, $default);
}
/**
* Set a configuration value.
*/
public function setConfigValue(string $key, mixed $value): void
{
$config = $this->config ?? [];
data_set($config, $key, $value);
$this->config = $config;
}
/**
* Set a limit value.
*/
public function setLimit(string $metric, int $value): void
{
$limits = $this->limits ?? [];
data_set($limits, $metric, $value);
$this->limits = $limits;
}
}

View File

@@ -0,0 +1,187 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BusinessModuleUsage extends Model
{
use HasFactory;
protected $table = 'business_module_usage';
protected $fillable = [
'business_id',
'module_key',
'metric',
'current_count',
'limit',
'period',
'period_start',
'period_end',
'limit_exceeded',
'last_incremented_at',
];
protected $casts = [
'current_count' => 'integer',
'limit' => 'integer',
'limit_exceeded' => 'boolean',
'period_start' => 'datetime',
'period_end' => 'datetime',
'last_incremented_at' => 'datetime',
];
/**
* Get the business that owns the usage record.
*/
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
/**
* Get the module definition.
*/
public function module(): BelongsTo
{
return $this->belongsTo(Module::class, 'module_key', 'key');
}
/**
* Scope a query to only include current period records.
*/
public function scopeCurrentPeriod(Builder $query): Builder
{
return $query->where('period_end', '>', now());
}
/**
* Scope a query to only include exceeded limits.
*/
public function scopeExceeded(Builder $query): Builder
{
return $query->where('limit_exceeded', true);
}
/**
* Increment the usage count.
*/
public function increment(int $amount = 1): bool
{
$this->current_count += $amount;
$this->last_incremented_at = now();
// Check if limit is exceeded
if ($this->limit !== null && $this->current_count > $this->limit) {
$this->limit_exceeded = true;
}
return $this->save();
}
/**
* Check if the usage limit is exceeded.
*/
public function isExceeded(): bool
{
if ($this->limit === null) {
return false;
}
return $this->current_count >= $this->limit;
}
/**
* Check if the usage is within limit.
*/
public function withinLimit(): bool
{
return ! $this->isExceeded();
}
/**
* Get the remaining usage.
*/
public function remaining(): ?int
{
if ($this->limit === null) {
return null;
}
return max(0, $this->limit - $this->current_count);
}
/**
* Get the usage percentage.
*/
public function percentage(): ?float
{
if ($this->limit === null) {
return null;
}
if ($this->limit === 0) {
return 100;
}
return min(100, ($this->current_count / $this->limit) * 100);
}
/**
* Check if the period has ended.
*/
public function isPeriodEnded(): bool
{
return $this->period_end->isPast();
}
/**
* Reset the usage count for a new period.
*/
public function reset(): bool
{
$this->current_count = 0;
$this->limit_exceeded = false;
$this->last_incremented_at = null;
// Calculate new period dates
[$periodStart, $periodEnd] = $this->calculatePeriodDates($this->period);
$this->period_start = $periodStart;
$this->period_end = $periodEnd;
return $this->save();
}
/**
* Calculate period start and end dates based on period type.
*/
protected function calculatePeriodDates(string $period): array
{
$start = now();
$end = match ($period) {
'daily' => now()->endOfDay(),
'monthly' => now()->endOfMonth(),
'yearly' => now()->endOfYear(),
'lifetime' => null,
default => now()->endOfMonth(),
};
return [$start, $end];
}
/**
* Reset if period has ended and return the instance.
*/
public function resetIfNeeded(): self
{
if ($this->isPeriodEnded() && $this->period !== 'lifetime') {
$this->reset();
}
return $this;
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\Pivot;
class BusinessUser extends Pivot
{
/**
* Indicates if the IDs are auto-incrementing.
*/
public $incrementing = false;
/**
* The attributes that should be cast.
*/
protected $casts = [
'permissions' => 'array',
'is_primary' => 'boolean',
];
}

View File

@@ -17,6 +17,7 @@ class Cart extends Model
'product_id',
'batch_id',
'brand_id',
'business_id',
'quantity',
'session_id',
];
@@ -57,6 +58,14 @@ class Cart extends Model
return $this->belongsTo(Batch::class);
}
/**
* Get the business associated with this cart item.
*/
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
/**
* Get the line total (quantity * product price).
*/

View File

@@ -2,6 +2,7 @@
namespace App\Models;
use App\Traits\BelongsToBusinessDirectly;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -12,7 +13,7 @@ use Illuminate\Support\Str;
class Component extends Model
{
use HasFactory, SoftDeletes;
use BelongsToBusinessDirectly, HasFactory, SoftDeletes;
protected $fillable = [
'business_id',
@@ -115,11 +116,6 @@ class Component extends Model
return $query->where('vendor_name', $vendorName);
}
public function scopeForBusiness($query, int $businessId)
{
return $query->where('business_id', $businessId);
}
public function scopeSellable($query)
{
return $query->where('is_sellable', true);

Some files were not shown because too many files have changed in this diff Show More