Compare commits

...

16 Commits

Author SHA1 Message Date
Jon
80b58d5973 Merge branch 'develop' into feat/cicd-optimization 2025-12-03 22:51:32 +00:00
kelly
93648ed001 Merge pull request 'fix: Route model binding fixes for 17 controllers' (#106) from fixes/first-dev-rollout into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/106
2025-12-03 22:41:30 +00:00
Jon Leopard
52b6bae17e feat(ci): optimize CI/CD pipeline for 2-env workflow
- Remove staging environment (dev + prod only)
- Add pre-built CI Docker image with PHP extensions
- Parallelize php-lint and code-style steps
- Skip tests on merge (already passed on PR)
- Keep seeder validation on develop push
- Update all documentation for new workflow
- Add Gitea branch protection guide

Estimated time savings: 3-5 min per feature

Amp-Thread-ID: https://ampcode.com/threads/T-51baf542-1045-4603-82d9-3c1ab24c0397
Co-authored-by: Amp <amp@ampcode.com>
2025-12-03 15:24:34 -07:00
kelly
88b201222f fix: use route model binding for Business in 16 controllers
Fixes "Attempt to read property 'id' on null" errors caused by
$request->user()->business pattern (User model has no business attribute).

Controllers fixed:
- Seller CRM: ThreadController, AutomationController, CrmCalendarController,
  CrmDashboardController, CrmSettingsController, DealController, InvoiceController,
  MeetingLinkController, QuoteController
- Seller Operations: DeliveryController, DeliveryWindowController, WashReportController
- Seller Marketing: CampaignController, ChannelController, TemplateController
- Buyer CRM: SettingsController

Changed from broken patterns:
- $request->user()->business
- Auth::user()->business
- currentBusiness()

To proper route model binding:
- Business $business (from {business} URL segment)

Note: FulfillmentWorkOrderController intentionally uses legacy pattern
($request->user()->businesses()->first()) for routes without {business} segment.
2025-12-03 15:19:23 -07:00
kelly
de402c03d5 Merge pull request 'feat: add dev environment seeders and fixes' (#105) from feature/dev-environment-seeders into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/105
2025-12-03 20:02:28 +00:00
kelly
b92ba4b86d feat: add dev environment seeders and fixes
- Add DevCleanupSeeder to remove non-Thunder Bud products (keeps only TB- prefix)
- Add DevMediaSyncSeeder to update brand/product media paths from MinIO
- Fix CustomerController to pass $benefits array to feature-disabled view
- Update BrandSeeder to include Twisties brand
- Make vite.config.js read VITE_PORT from env (fixes port conflict)

Run on dev.cannabrands.app:
  php artisan db:seed --class=DevCleanupSeeder
  php artisan db:seed --class=DevMediaSyncSeeder
2025-12-03 12:47:33 -07:00
Jon
f8f219f00b Merge pull request 'feat: consolidate suites to 7 active and update user management UI' (#104) from feature/suite-consolidation-and-user-permissions into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/104
2025-12-03 18:06:07 +00:00
kelly
f16dac012d feat: consolidate suites to 7 active and update user management UI
- Consolidate from 14+ suites to 7 active suites (sales, processing,
  manufacturing, delivery, management, brand_manager, dispensary)
- Mark 9 legacy suites as deprecated (is_active=false)
- Update DepartmentSuitePermission with granular permissions per suite
- Update DevSuitesSeeder with correct business→suite assignments
- Rebuild Settings > Users page with new role system (owner/admin/manager/member)
- Add department assignment UI with department roles (operator/lead/supervisor/manager)
- Add suite-based permission overrides with tabbed UI
- Move SUITES_AND_PRICING_MODEL.md to docs root
- Add USER_MANAGEMENT.md documentation
2025-12-03 09:59:22 -07:00
Jon
f566b83cc6 Merge pull request 'fix: use /up health endpoint for K8s probes' (#103) from fix/health-probe-path into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/103
2025-12-03 08:07:26 +00:00
Jon Leopard
418da7a39e fix: use /up health endpoint for K8s probes
The root path (/) redirects to /register or /login when unauthenticated,
causing readiness/liveness probes to fail with 302 responses.

Laravel's /up health endpoint always returns 200 OK.
2025-12-03 01:00:52 -07:00
Jon
3c6fe92811 Merge pull request 'fix: force HTTPS scheme for asset URLs in non-local environments' (#102) from fix/force-https-scheme into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/102
2025-12-03 07:28:14 +00:00
Jon
7d3243b67e Merge branch 'develop' into fix/force-https-scheme 2025-12-03 07:20:30 +00:00
Jon Leopard
8f6597f428 fix: force HTTPS scheme for asset URLs in non-local environments
Filament v4 uses dynamic imports for components like tabs.js and select.js.
These use Laravel's asset() helper which doesn't automatically respect
X-Forwarded-Proto from TrustProxies middleware.

When SSL terminates at the K8s ingress, PHP sees HTTP requests, so asset()
generates HTTP URLs. The browser then blocks these as 'Mixed Content' when
the page is served over HTTPS.

URL::forceScheme('https') ensures all generated URLs use HTTPS in
development, staging, and production environments.
2025-12-03 00:17:04 -07:00
Jon
64d38b8b2f Merge pull request 'fix: add asset_url config key for ASSET_URL env var to work' (#101) from fix/filament-mixed-content-asset-url into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/101
2025-12-03 06:55:35 +00:00
Jon Leopard
7aa366eda9 fix: add asset_url config key for ASSET_URL env var to work
Laravel 11's minimal config/app.php doesn't include the asset_url
key by default. Without this, the ASSET_URL environment variable
is never read, causing asset() to not use the configured URL.
2025-12-02 23:43:52 -07:00
Jon
d7adaf0cba Merge pull request 'fix: add ASSET_URL to resolve Filament v4 mixed content errors' (#100) from fix/asset-url-mixed-content into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/100
2025-12-03 06:24:17 +00:00
48 changed files with 3406 additions and 6508 deletions

View File

@@ -1,19 +1,26 @@
# Woodpecker CI/CD Pipeline for Cannabrands Hub
# Documentation: https://woodpecker-ci.org/docs/intro
#
# 3-Environment Workflow:
# - develop branch → dev.cannabrands.app (unstable, daily integration)
# - master branch → staging.cannabrands.app (stable, pre-production)
# - tags (2025.X) → cannabrands.app (production releases)
# 2-Environment Workflow (Optimized for small team):
# - develop branch → dev.cannabrands.app (integration/testing)
# - master branch → cannabrands.app (production)
# - tags (2025.X) → cannabrands.app (versioned production releases)
#
# Pipeline optimizations:
# - Pre-built CI image with PHP extensions (saves ~60-90s)
# - Parallel lint + code-style checks
# - Skip tests on merge if PR tests passed
# - Conditional seeder validation (develop only)
when:
- branch: [develop, master]
event: push
- event: [pull_request, tag]
# Install dependencies first (needed for php-lint to resolve traits/classes)
# ============================================
# STEP 1: Restore Composer Cache
# ============================================
steps:
# Restore Composer cache
restore-composer-cache:
image: meltwater/drone-cache:dev
settings:
@@ -26,18 +33,13 @@ steps:
volumes:
- /tmp/woodpecker-cache:/tmp/cache
# Install dependencies
# ============================================
# STEP 2: Install Composer Dependencies
# ============================================
# Uses pre-built CI image with all PHP extensions
composer-install:
image: php:8.3-cli
image: code.cannabrands.app/cannabrands/ci-runner:latest
commands:
- echo "Installing system dependencies..."
- apt-get update -qq
- apt-get install -y -qq git zip unzip libicu-dev libzip-dev libpng-dev libjpeg-dev libfreetype6-dev libpq-dev
- echo "Installing PHP extensions..."
- docker-php-ext-configure gd --with-freetype --with-jpeg
- docker-php-ext-install -j$(nproc) intl pdo pdo_pgsql zip gd pcntl
- 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'
@@ -59,15 +61,16 @@ steps:
- |
if [ -d "vendor" ] && [ -f "vendor/autoload.php" ]; then
echo "✅ Restored vendor from cache"
echo "Verifying cached dependencies are up to date..."
composer install --no-interaction --prefer-dist --optimize-autoloader --no-progress
else
echo "📦 Installing fresh dependencies (cache miss)"
composer install --no-interaction --prefer-dist --optimize-autoloader --no-progress
fi
- echo "Composer dependencies ready!"
- echo "Composer dependencies ready!"
# Rebuild Composer cache
# ============================================
# STEP 3: Rebuild Composer Cache
# ============================================
rebuild-composer-cache:
image: meltwater/drone-cache:dev
settings:
@@ -80,27 +83,31 @@ steps:
volumes:
- /tmp/woodpecker-cache:/tmp/cache
# PHP Syntax Check (runs after composer install so traits/classes are available)
# ============================================
# STEP 4: PHP Lint + Code Style (PARALLEL)
# ============================================
# These run in parallel - both only need vendor/
php-lint:
image: php:8.3-cli
image: code.cannabrands.app/cannabrands/ci-runner:latest
group: quality-checks
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!"
- find app -name "*.php" -exec php -l {} \; 2>&1 | grep -v "No syntax errors"
- find routes -name "*.php" -exec php -l {} \; 2>&1 | grep -v "No syntax errors"
- find database -name "*.php" -exec php -l {} \; 2>&1 | grep -v "No syntax errors"
- echo "PHP syntax check complete!"
# Run Laravel Pint (Code Style)
code-style:
image: php:8.3-cli
image: code.cannabrands.app/cannabrands/ci-runner:latest
group: quality-checks
commands:
- echo "Checking code style with Laravel Pint..."
- ./vendor/bin/pint --test
- echo "Code style check complete!"
- 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
# ============================================
# STEP 5: PHPUnit Tests
# ============================================
tests:
image: kirschbaumdevelopment/laravel-test-runner:8.3
environment:
@@ -131,11 +138,13 @@ steps:
- sleep 2
- echo "Running tests..."
- php artisan test --parallel
- echo "Tests complete!"
- echo "Tests complete!"
# Validate seeders that run in dev/staging environments
# This prevents deployment failures caused by seeder errors (e.g., fake() crashes)
# Uses APP_ENV=development to match K8s init container behavior
# ============================================
# STEP 6: Validate Seeders (develop push only)
# ============================================
# Runs only on direct push to develop to catch seeder issues
# before auto-deploy. Skipped on PRs since tests already run migrations.
validate-seeders:
image: kirschbaumdevelopment/laravel-test-runner:8.3
environment:
@@ -157,11 +166,12 @@ steps:
- php artisan migrate:fresh --seed --force
- echo "✅ Seeder validation complete!"
when:
branch: [develop, master]
branch: develop
event: push
status: success
# Build and push Docker image for DEV environment (develop branch)
# ============================================
# STEP 7: Build Docker Image for DEV (develop branch)
# ============================================
build-image-dev:
image: woodpeckerci/plugin-docker-buildx
settings:
@@ -172,10 +182,10 @@ steps:
password:
from_secret: gitea_token
tags:
- 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)
- dev
- dev-${CI_COMMIT_SHA:0:7}
- sha-${CI_COMMIT_SHA:0:7}
- ${CI_COMMIT_BRANCH}
build_args:
GIT_COMMIT_SHA: "${CI_COMMIT_SHA:0:7}"
APP_VERSION: "dev"
@@ -189,9 +199,10 @@ steps:
when:
branch: develop
event: push
status: success
# Auto-deploy to dev.cannabrands.app (develop branch only)
# ============================================
# STEP 8: Auto-Deploy to dev.cannabrands.app
# ============================================
deploy-dev:
image: bitnami/kubectl:latest
environment:
@@ -200,36 +211,29 @@ steps:
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:
# ============================================
# STEP 9: Build Docker Image for PRODUCTION (master branch)
# ============================================
# Skips tests on merge - they already passed on the PR
build-image-production:
image: woodpeckerci/plugin-docker-buildx
settings:
registry: code.cannabrands.app
@@ -239,21 +243,56 @@ steps:
password:
from_secret: gitea_token
tags:
- staging # Latest staging build → staging.cannabrands.app
- sha-${CI_COMMIT_SHA:0:7} # Commit SHA (industry standard)
- ${CI_COMMIT_BRANCH} # Branch name (master)
- latest
- prod-${CI_COMMIT_SHA:0:7}
- sha-${CI_COMMIT_SHA:0:7}
- ${CI_COMMIT_BRANCH}
build_args:
GIT_COMMIT_SHA: "${CI_COMMIT_SHA:0:7}"
APP_VERSION: "staging"
APP_VERSION: "production"
VITE_REVERB_APP_KEY: "YOUR_PRODUCTION_REVERB_KEY"
VITE_REVERB_HOST: "cannabrands.app"
VITE_REVERB_PORT: "443"
VITE_REVERB_SCHEME: "https"
cache_images:
- code.cannabrands.app/cannabrands/hub:buildcache-staging
- code.cannabrands.app/cannabrands/hub:buildcache-prod
platforms: linux/amd64
when:
branch: master
event: push
status: success
# Build and push Docker image for PRODUCTION (tagged releases)
# ============================================
# STEP 10: Deploy to Production (master branch)
# ============================================
deploy-production:
image: bitnami/kubectl:latest
environment:
KUBECONFIG_CONTENT:
from_secret: kubeconfig_prod
commands:
- echo "🚀 Deploying to PRODUCTION (cannabrands.app)..."
- echo "Commit SHA${CI_COMMIT_SHA:0:7}"
- mkdir -p ~/.kube
- echo "$KUBECONFIG_CONTENT" | tr -d '[:space:]' | base64 -d > ~/.kube/config
- chmod 600 ~/.kube/config
- |
kubectl set image deployment/cannabrands-hub \
app=code.cannabrands.app/cannabrands/hub:prod-${CI_COMMIT_SHA:0:7} \
migrate=code.cannabrands.app/cannabrands/hub:prod-${CI_COMMIT_SHA:0:7} \
-n cannabrands-prod
- kubectl rollout status deployment/cannabrands-hub -n cannabrands-prod --timeout=300s
- |
echo ""
echo "✅ PRODUCTION deployment successful!"
echo "Pod status:"
kubectl get pods -n cannabrands-prod -l app=cannabrands-hub
when:
branch: master
event: push
# ============================================
# STEP 11: Build Tagged Release (optional versioned releases)
# ============================================
build-image-release:
image: woodpeckerci/plugin-docker-buildx
settings:
@@ -264,8 +303,8 @@ steps:
password:
from_secret: gitea_token
tags:
- ${CI_COMMIT_TAG} # CalVer tag (e.g., 2025.10.1)
- latest # Latest stable release
- ${CI_COMMIT_TAG}
- latest
build_args:
GIT_COMMIT_SHA: "${CI_COMMIT_SHA:0:7}"
APP_VERSION: "${CI_COMMIT_TAG}"
@@ -274,89 +313,56 @@ steps:
platforms: linux/amd64
when:
event: tag
status: success
# Success notification
# ============================================
# Success Notification
# ============================================
success:
image: alpine:latest
when:
- evaluate: 'CI_PIPELINE_STATUS == "success"'
commands:
- echo "✅ Pipeline completed successfully!"
- echo "All checks passed for commit ${CI_COMMIT_SHA:0:7}"
- echo "Commit ${CI_COMMIT_SHA:0:7}"
- |
if [ "${CI_PIPELINE_EVENT}" = "tag" ]; then
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "🎉 PRODUCTION RELEASE BUILD COMPLETE"
echo "🎉 TAGGED RELEASE BUILD COMPLETE"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Version: ${CI_COMMIT_TAG}"
echo "Registry: code.cannabrands.app/cannabrands/hub"
echo ""
echo "Available as:"
echo " - code.cannabrands.app/cannabrands/hub:${CI_COMMIT_TAG}"
echo " - code.cannabrands.app/cannabrands/hub:latest"
echo ""
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!"
echo "Image: code.cannabrands.app/cannabrands/hub:${CI_COMMIT_TAG}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
elif [ "${CI_PIPELINE_EVENT}" = "push" ] && [ "${CI_COMMIT_BRANCH}" = "master" ]; then
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "🧪 STAGING BUILD COMPLETE"
echo "🚀 PRODUCTION DEPLOYED"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Branch: master"
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.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.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'"
echo " git push origin 2025.10.1"
echo "Site: https://cannabrands.app"
echo "Image: prod-${CI_COMMIT_SHA:0:7}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
elif [ "${CI_PIPELINE_EVENT}" = "push" ] && [ "${CI_COMMIT_BRANCH}" = "develop" ]; then
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "🚀 DEV BUILD + AUTO-DEPLOY COMPLETE"
echo "🧪 DEV DEPLOYED"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Branch: develop"
echo "Commit: ${CI_COMMIT_SHA:0:7}"
echo "Site: https://dev.cannabrands.app"
echo "Image: dev-${CI_COMMIT_SHA:0:7}"
echo ""
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 "Ready for production? Open PR: develop → master"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
elif [ "${CI_PIPELINE_EVENT}" = "pull_request" ]; then
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. 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 "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "✅ PR CHECKS PASSED"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Ready to merge to master for production deployment."
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
fi
# Services for tests
# ============================================
# Services for Tests
# ============================================
services:
postgres:
image: postgres:15

View File

@@ -1,623 +0,0 @@
# CI/CD Flow Strategies and Best Practices
## Current State: Continuous Integration (CI Only)
**What you have now:**
```
Developer → Commit → Push to master → Woodpecker runs:
1. PHP syntax check
2. Composer install (cached)
3. Laravel Pint (code style)
4. PHPUnit tests
→ ✅ Pass = Code is verified
→ ❌ Fail = Fix before merging
```
**Current flow**: **Continuous Integration** - automated testing, no deployment
---
## CI/CD Strategies: Three Approaches
### Strategy 1: Continuous Delivery (Safest - Recommended for Cannabis Industry)
**Flow:**
```
Developer → Feature branch → Pull Request → CI tests pass
→ Manual review & approval → Merge to master → CI tests again
→ Build Docker image → Push to Gitea registry
→ MANUAL deployment to production (click button/run command)
```
**Characteristics:**
- ✅ **Human gate before production** - manual approval required
- ✅ **Compliance-friendly** - audit trail of who deployed what
- ✅ **Safer for regulated industries** - no automatic production changes
- ✅ **Rollback control** - can choose which version to deploy
- ⚠️ Requires human intervention for each deployment
**Best for**: Cannabis industry (regulated), financial services, healthcare
**Example Woodpecker pipeline:**
```yaml
steps:
# ... tests ...
# Build image automatically on master
build-image:
image: plugins/docker
settings:
registry: code.cannabrands.app
repo: code.cannabrands.app/cannabrands/hub
tags: [latest, ${CI_COMMIT_SHA:0:8}]
when:
branch: master
event: push
# Notify team that new version is ready
notify-ready:
image: alpine
commands:
- echo "✅ Image ready for deployment"
- echo "To deploy: ssh prod-server 'docker pull ... && docker-compose up -d'"
when:
branch: master
```
**Manual deployment:**
```bash
# On production server
ssh cannabrands-prod
docker pull code.cannabrands.app/cannabrands/hub:bef77df8
docker-compose up -d
# Or use deployment tool like Ansible, Deployer, etc.
```
---
### Strategy 2: Continuous Deployment (Most Automated - Higher Risk)
**Flow:**
```
Developer → Push to master → CI tests pass
→ Build Docker image → Push to registry
→ AUTOMATIC deployment to production
→ Health checks → Rollback if fails
```
**Characteristics:**
- ✅ **Fastest delivery** - code in production within minutes
- ✅ **High velocity** - deploy 10+ times per day
- ⚠️ **Higher risk** - bugs can reach production automatically
- ⚠️ **Requires excellent tests** - 80%+ code coverage needed
- ⚠️ **May not meet compliance requirements** - no human review
**Best for**: SaaS startups, internal tools, non-regulated industries
**Example Woodpecker pipeline:**
```yaml
steps:
# ... tests ...
build-and-deploy:
image: appleboy/drone-ssh
settings:
host: cannabrands-prod.example.com
username: deploy
key:
from_secret: ssh_private_key
script:
- cd /var/www/cannabrands
- 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
when:
branch: master
status: success
```
**⚠️ Not recommended for cannabis industry** - regulatory compliance issues
---
### Strategy 3: Environment Promotion (Balanced - Recommended)
**Flow:**
```
Developer → Feature branch → CI tests
→ Merge to develop → Auto-deploy to STAGING
→ Manual testing on staging
→ Merge to master → Auto-deploy to PRODUCTION
```
**Characteristics:**
- ✅ **Staged rollout** - test in staging before production
- ✅ **Automated staging** - fast feedback on real environment
- ✅ **Manual production gate** - controlled production releases
- ✅ **Mirrors production** - catch environment-specific issues
**Best for**: Most production applications, including cannabis industry
**Git branching:**
```
develop (latest features) → auto-deploy to staging.cannabrands.app
master (production-ready) → auto-deploy to app.cannabrands.com
```
**Example Woodpecker pipeline:**
```yaml
steps:
# ... tests ...
# Deploy to staging automatically
deploy-staging:
image: appleboy/drone-ssh
settings:
host: staging.cannabrands.app
username: deploy
key:
from_secret: ssh_private_key
script:
- cd /var/www/cannabrands
- docker pull code.cannabrands.app/cannabrands/hub:${CI_COMMIT_SHA:0:8}
- docker-compose up -d
when:
branch: develop
event: push
# Deploy to production automatically (only from master)
deploy-production:
image: appleboy/drone-ssh
settings:
host: app.cannabrands.com
username: deploy
key:
from_secret: ssh_private_key
script:
- cd /var/www/cannabrands
- docker pull code.cannabrands.app/cannabrands/hub:${CI_COMMIT_SHA:0:8}
- docker-compose up -d
when:
branch: master
event: push
```
---
## Recommended Strategy for Cannabrands
### Phase 1: Current (Continuous Integration)
**Status**: ✅ Implemented
- Tests run on every push to master
- No automatic deployment
- Manual deployment when ready
### Phase 2: Continuous Delivery (Next 1-3 months)
**Recommended approach:**
1. **Keep CI testing** (already have this)
2. **Add image building** on master branch
3. **Manual deployment** to production
4. **Deployment checklist** for compliance
**Why this approach:**
- Cannabis industry is regulated - need audit trails
- Manual gate ensures compliance review
- Still fast (deploy in 5 minutes when ready)
- Safer for financial/inventory systems
### Phase 3: Environment Promotion (Future - when team grows)
**When to implement:**
- When you have 3+ developers
- When you add a QA team member
- When compliance requires staging environment
- When you need faster iteration
---
## Git Branching Strategies
### Option 1: GitHub Flow (Simple - Current)
```
master (production)
feature branches → PR → merge to master
```
**Pros:**
- Simple to understand
- Works well for small teams (1-3 people)
- Fast iteration
**Cons:**
- No staging environment
- Every merge to master should be production-ready
**Current fit**: ✅ Perfect for now
---
### Option 2: Git Flow (Comprehensive)
```
master (production) ← hotfixes
release branches (release prep)
develop (integration) ← feature branches
```
**Pros:**
- Clear separation of environments
- Supports multiple versions
- Good for larger teams
**Cons:**
- More complex
- Overkill for small teams
- Slower iteration
**Future fit**: Consider when team grows to 5+ developers
---
### Option 3: Trunk-Based Development (Modern)
```
master (trunk) ← short-lived feature branches
Continuous deployment with feature flags
```
**Pros:**
- Fastest iteration
- Simple branching model
- Forces small, frequent commits
**Cons:**
- Requires feature flags
- Requires excellent test coverage
- Not ideal for regulated industries
**Fit**: ❌ Not recommended for cannabis compliance
---
## Best Practices for Cannabrands CI/CD
### 1. Testing Requirements
**Minimum coverage:**
- ✅ Unit tests for critical business logic (invoice calculations, inventory)
- ✅ Feature tests for compliance workflows (age verification, state restrictions)
- ✅ Browser tests for checkout flow (using Laravel Dusk)
**Current state**: You have tests, need to ensure coverage of:
- Invoice generation (especially with picked quantities)
- Payment term calculations
- Order state transitions
- Manifest generation (cannabis compliance)
### 2. Deployment Checklist
Create `.woodpecker/DEPLOYMENT_CHECKLIST.md`:
```markdown
# Pre-Deployment Checklist
Before deploying to production:
## Automated (CI checks these)
- [ ] All tests pass
- [ ] Code style passes (Pint)
- [ ] No PHP syntax errors
## Manual (Human checks these)
- [ ] Database migrations reviewed (no data loss)
- [ ] Environment variables updated (.env)
- [ ] Compliance implications reviewed
- [ ] Rollback plan documented
- [ ] Customer-facing changes tested in staging
## Post-Deployment
- [ ] Health check passes (app responding)
- [ ] Key workflows tested (place order, generate invoice)
- [ ] Error monitoring checked (last 5 minutes)
- [ ] Database migrations completed successfully
```
### 3. Environment Configuration
**Three environments:**
```
Local Development:
- Docker Compose (Sail)
- .env with local credentials
- Seeded test data
Staging:
- Mirrors production
- staging.cannabrands.app
- Real-like data (anonymized)
- Auto-deployed from develop branch
Production:
- app.cannabrands.com
- Real customer data
- Manual deployment (compliance gate)
- Backups every 4 hours
```
### 4. Deployment Windows
**Cannabis industry considerations:**
- **No deployments during business hours** (8am-6pm PST) - peak order time
- **Preferred windows**:
- Tuesday/Wednesday 10pm-midnight (lowest traffic)
- Sunday 8pm-10pm (acceptable)
- **Avoid**:
- Mondays (busy)
- Fridays (risky - weekend support issues)
- Last day of month (invoicing period)
### 5. Rollback Strategy
**Always have a rollback plan:**
```bash
# Quick rollback (under 2 minutes)
ssh cannabrands-prod
docker pull code.cannabrands.app/cannabrands/hub:PREVIOUS_COMMIT_SHA
docker-compose up -d
# Database rollback (if migrations ran)
docker exec cannabrands php artisan migrate:rollback --step=1
```
**Tag stable releases:**
```bash
# Before risky deployments
git tag -a v1.5.2-stable -m "Last known good version"
git push origin v1.5.2-stable
```
### 6. Secrets Management
**Never commit:**
- API keys (Metrc, payment processors)
- Database credentials
- OAuth secrets
**Use Woodpecker secrets for:**
- Gitea registry credentials
- SSH deployment keys
- Notification webhooks
**Use environment variables for:**
- Feature flags
- Third-party API endpoints
- Email/SMS providers
### 7. Monitoring & Alerts
**Must-have monitoring:**
- **Application errors**: Laravel Telescope (dev), Sentry (production)
- **Server health**: Uptime monitoring (every 5 minutes)
- **Database**: Connection pool, slow queries
- **Key workflows**: "Can users place orders?" synthetic test
**Alert on:**
- ❌ Application throws 500 errors
- ❌ Database connection fails
- ❌ Manifest generation fails (compliance risk!)
- ❌ Payment processing fails
### 8. Compliance Considerations
**Cannabis-specific requirements:**
- **Audit logging**: Who deployed what, when
- **Data retention**: Keep deployment logs for 7 years (some states require this)
- **Rollback capability**: Must be able to restore system to previous state
- **Change approval**: Document who approved production changes
**Implement:**
```yaml
# Log all deployments to compliance audit trail
steps:
deploy-production:
# ... deployment steps ...
audit-log:
image: alpine
commands:
- |
echo "DEPLOYMENT AUDIT LOG" > /tmp/audit.log
echo "Timestamp: $(date -Iseconds)" >> /tmp/audit.log
echo "Commit: ${CI_COMMIT_SHA}" >> /tmp/audit.log
echo "Author: ${CI_COMMIT_AUTHOR}" >> /tmp/audit.log
echo "Branch: ${CI_COMMIT_BRANCH}" >> /tmp/audit.log
# Send to compliance logging system
curl -X POST https://logs.cannabrands.com/deployments \
-H "Content-Type: application/json" \
-d @/tmp/audit.log
when:
branch: master
status: success
```
---
## Recommended Next Steps
### Immediate (This month)
1. ✅ **Keep current CI setup** - tests on every push
2. ⬜ **Add image building** - build Docker images on master
3. ⬜ **Document deployment process** - create runbook
4. ⬜ **Set up monitoring** - add Sentry or similar
### Short-term (1-3 months)
1. ⬜ **Add staging environment** - test before production
2. ⬜ **Implement deployment checklist** - compliance gate
3. ⬜ **Add deployment logging** - audit trail
4. ⬜ **Create rollback runbook** - quick recovery
### Long-term (6+ months)
1. ⬜ **Add automated staging deployment** - from develop branch
2. ⬜ **Implement feature flags** - safer rollouts
3. ⬜ **Add smoke tests** - post-deployment verification
4. ⬜ **Consider blue-green deployments** - zero-downtime
---
## Example: Full Recommended Pipeline
```yaml
# .woodpecker/.ci.yml - Recommended for Cannabrands
when:
branch: [master, develop]
event: [push, pull_request]
steps:
# TEST PHASE (all branches)
restore-cache:
image: meltwater/drone-cache:dev
settings:
backend: filesystem
restore: true
cache_key: "composer-{{ checksum \"composer.lock\" }}"
mount: ["vendor"]
volumes:
- /tmp/woodpecker-cache:/tmp/cache
composer-install:
image: php:8.3-cli
commands:
- apt-get update -qq && apt-get install -y -qq git zip unzip libicu-dev libzip-dev libpq-dev
- docker-php-ext-install -j$(nproc) intl pdo pdo_pgsql zip
- curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
- composer install --no-interaction --prefer-dist --optimize-autoloader
php-lint:
image: php:8.3-cli
commands:
- find app routes database -name "*.php" -exec php -l {} \;
code-style:
image: php:8.3-cli
commands:
- ./vendor/bin/pint --test
tests:
image: kirschbaumdevelopment/laravel-test-runner:8.3
environment:
APP_ENV: testing
DB_CONNECTION: pgsql
DB_HOST: postgres
DB_PORT: 5432
DB_DATABASE: testing
DB_USERNAME: testing
DB_PASSWORD: testing
commands:
- cp .env.example .env
- php artisan key:generate
- php artisan test --parallel
rebuild-cache:
image: meltwater/drone-cache:dev
settings:
backend: filesystem
rebuild: true
cache_key: "composer-{{ checksum \"composer.lock\" }}"
mount: ["vendor"]
volumes:
- /tmp/woodpecker-cache:/tmp/cache
# BUILD PHASE (master and develop only)
build-image:
image: plugins/docker
settings:
registry: code.cannabrands.app
repo: code.cannabrands.app/cannabrands/hub
tags:
- ${CI_COMMIT_BRANCH}
- ${CI_COMMIT_SHA:0:8}
username:
from_secret: gitea_username
password:
from_secret: gitea_token
when:
branch: [master, develop]
event: push
# DEPLOY TO STAGING (develop branch only)
deploy-staging:
image: appleboy/drone-ssh
settings:
host: staging.cannabrands.app
username: deploy
key:
from_secret: staging_ssh_key
script:
- cd /var/www/cannabrands
- 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
when:
branch: develop
event: push
status: success
# NOTIFY FOR PRODUCTION (master branch - manual deploy required)
notify-production-ready:
image: alpine
commands:
- echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
- echo "✅ PRODUCTION IMAGE READY"
- echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
- echo "Commit: ${CI_COMMIT_SHA:0:8}"
- echo "Author: ${CI_COMMIT_AUTHOR}"
- echo "Message: ${CI_COMMIT_MESSAGE}"
- echo ""
- echo "To deploy to production:"
- echo " ssh cannabrands-prod"
- echo " cd /var/www/cannabrands"
- 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!"
- echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
when:
branch: master
event: push
status: success
services:
postgres:
image: postgres:15
environment:
POSTGRES_USER: testing
POSTGRES_PASSWORD: testing
POSTGRES_DB: testing
```
---
## Summary
**For Cannabrands, I recommend:**
1. **Current (now)**: Continuous Integration with manual deployment
2. **Next phase**: Continuous Delivery with staging environment
3. **Git strategy**: GitHub Flow (simple, works for small teams)
4. **Deployment**: Manual to production (compliance requirement)
5. **Monitoring**: Add error tracking and uptime monitoring
**Why this approach:**
- ✅ Meets cannabis industry compliance needs
- ✅ Balances speed with safety
- ✅ Scales as team grows
- ✅ Maintains audit trails
- ✅ Allows quick rollbacks
**Key principle**: *"Automate testing, control deployment"*

View File

@@ -0,0 +1,131 @@
# Gitea Branch Protection Configuration
## Recommended Settings for 2-Person Team
### `develop` Branch — **UNPROTECTED**
Direct push allowed for fast iteration. CI runs on every push.
**Settings in Gitea:**
1. Go to Repository → Settings → Branches
2. Find or add `develop` branch rule
3. **Disable all protection** or delete the rule entirely
If you want minimal protection, use these settings:
- ☐ Enable Branch Protection (unchecked)
- ☐ Disable Push (unchecked)
- ☐ Require Pull Request (unchecked)
---
### `master` Branch — **PROTECTED** (Production)
Requires PR from develop. CI must pass before merge.
**Settings in Gitea:**
1. Go to Repository → Settings → Branches
2. Add branch protection rule for `master`
3. Configure:
```
☑ Enable Branch Protection
☐ Disable Push # Allow merge commits
☑ Require Pull Request
☐ Require Approvals: 0 # Optional for 2-person team
☑ Block Merge on Rejected Reviews # Optional
☑ Block Merge on Outdated Branch # Recommended
☑ Status Check Patterns:
- continuous-integration/woodpecker
```
**Screenshot Guide:**
```
Branch Protection for: master
├── [x] Enable Branch Protection
├── [ ] Disable Push
├── [x] Require Pull Request
│ └── Require Approvals: 0 (or 1 if you want peer review)
├── [x] Block Merge on Outdated Branch
├── [x] Block Merge on Official Review Requests
└── [x] Status Check Patterns
└── continuous-integration/woodpecker
```
---
## Step-by-Step Instructions
### 1. Navigate to Branch Protection
```
https://code.cannabrands.app/cannabrands/hub/settings/branches
```
### 2. Remove Protection from `develop`
- Click on `develop` rule (if exists)
- Click "Delete Rule" or uncheck "Enable Branch Protection"
- Save
### 3. Add/Update Protection for `master`
- Click "Add New Rule" or edit existing `master` rule
- Branch name pattern: `master`
- Enable settings as shown above
- Save
---
## Workflow After Changes
| Action | Before | After |
|--------|--------|-------|
| Push to develop | ❌ Blocked (PR required) | ✅ Direct push allowed |
| Merge develop → master | Via PR | Via PR (unchanged) |
| CI on develop push | Runs tests | Runs tests + builds + deploys |
| CI on master merge | Runs everything | Builds + deploys (tests already passed on PR) |
---
## Verification
After making these changes, verify:
1. **Test develop push:**
```bash
git checkout develop
echo "# test" >> README.md
git commit -am "test: verify develop push"
git push origin develop
# Should succeed without PR
```
2. **Test master protection:**
```bash
git checkout master
echo "# test" >> README.md
git commit -am "test: verify master protection"
git push origin master
# Should FAIL with "protected branch" error
```
3. **Test PR workflow:**
- Open PR: develop → master
- Wait for CI to pass
- Merge should be allowed
---
## Rollback (If Needed)
To re-protect `develop`:
1. Go to Repository → Settings → Branches
2. Add rule for `develop`
3. Enable "Require Pull Request"
4. Save
---
**Note:** These settings optimize for a 2-person team using Claude Code with automated tests. As your team grows, consider adding approval requirements.

View File

@@ -1,602 +0,0 @@
# Pre-Release Deployment Strategy
## Your Current Situation
**Reality Check:**
- ✅ App is functional but incomplete
- ✅ No external customers yet
- ✅ Need team testing ASAP
- ✅ Master branch = active development
- ✅ Budget/time conscious
**This is normal for early-stage startups!** Most companies go through this phase.
---
## The Pre-Release Paradox
**The Challenge:**
- You need colleagues to test the app
- The app isn't production-ready
- Traditional CI/CD assumes "production" means customers
- But you don't have customers yet!
**The Solution:**
Treat your **pre-release testing environment** as if it were production, even though it's not.
---
## Recommended Pre-Release Strategy
### Phase 0: Pre-Release (You Are Here)
**Environment Setup:**
```
master branch → CI tests pass → Build image → Deploy to dev.cannabrands.app
Team tests here (internal only)
```
**Characteristics:**
- ✅ Fast iteration - deploy on every master push
- ✅ No customer impact - internal testing only
- ✅ "Production-like" environment for realistic testing
- ✅ Can be unstable - teammates understand this
- ⚠️ Data may be reset periodically
**Who uses it:**
- Internal developers
- Co-founders
- Early advisors/consultants
- Pre-launch beta testers (with NDAs)
---
## 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.app)
- SSL certificate (Let's Encrypt)
**Create deployment user:**
```bash
# On dev.cannabrands.app server
sudo adduser deployer
sudo usermod -aG docker deployer
sudo mkdir -p /var/www/cannabrands
sudo chown deployer:deployer /var/www/cannabrands
```
**Generate SSH key for deployment:**
```bash
# On your local machine or CI server
ssh-keygen -t ed25519 -C "woodpecker-deploy" -f ~/.ssh/cannabrands_deploy
# Add public key to server
ssh deployer@dev.cannabrands.app
mkdir -p ~/.ssh
nano ~/.ssh/authorized_keys
# Paste public key, save
```
**Add SSH key to Woodpecker:**
```bash
# In Woodpecker UI:
# Settings → Secrets → Add Secret
# Name: dev_ssh_key
# Value: [paste PRIVATE key contents]
```
---
### Step 2: Create Deployment Docker Compose
**On dev.cannabrands.app server:**
```bash
cd /var/www/cannabrands
nano docker-compose.yml
```
**Simple production-like docker-compose.yml:**
```yaml
version: '3.8'
services:
app:
image: code.cannabrands.app/cannabrands/hub:latest
container_name: cannabrands_app
restart: unless-stopped
ports:
- "8000:8000"
environment:
- APP_ENV=development
- APP_DEBUG=true
- APP_URL=https://dev.cannabrands.app
- DB_HOST=postgres
- DB_PORT=5432
- DB_DATABASE=cannabrands
- DB_USERNAME=cannabrands
- DB_PASSWORD=${DB_PASSWORD}
volumes:
- ./storage:/var/www/html/storage
- ./.env:/var/www/html/.env
depends_on:
- postgres
command: php artisan serve --host=0.0.0.0 --port=8000
postgres:
image: postgres:15
container_name: cannabrands_db
restart: unless-stopped
environment:
POSTGRES_DB: cannabrands
POSTGRES_USER: cannabrands
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
nginx:
image: nginx:alpine
container_name: cannabrands_nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./ssl:/etc/nginx/ssl:ro
depends_on:
- app
volumes:
postgres_data:
```
**Create .env file:**
```bash
nano .env
```
```bash
APP_NAME="Cannabrands Dev"
APP_ENV=development
APP_KEY=base64:YOUR_KEY_HERE
APP_DEBUG=true
APP_URL=https://dev.cannabrands.app
DB_CONNECTION=pgsql
DB_HOST=postgres
DB_PORT=5432
DB_DATABASE=cannabrands
DB_USERNAME=cannabrands
DB_PASSWORD=SECURE_PASSWORD_HERE
# Use your actual credentials
MAIL_MAILER=log
CACHE_DRIVER=redis
SESSION_DRIVER=redis
QUEUE_CONNECTION=redis
REDIS_HOST=redis
REDIS_PASSWORD=null
REDIS_PORT=6379
```
---
### Step 3: Update Woodpecker Pipeline
**Add deployment steps to `.woodpecker/.ci.yml`:**
```yaml
# Add after existing test steps
steps:
# ... existing steps (tests, etc.) ...
# BUILD DOCKER IMAGE (only on master)
build-image:
image: woodpeckerci/plugin-docker-buildx
settings:
registry: code.cannabrands.app
repo: code.cannabrands.app/cannabrands/hub
username:
from_secret: gitea_username
password:
from_secret: gitea_token
tags:
- latest
- dev-${CI_COMMIT_SHA:0:8}
when:
branch: master
event: push
status: success
# DEPLOY TO DEV ENVIRONMENT
deploy-dev:
image: appleboy/drone-ssh
settings:
host: dev.cannabrands.app
username: deployer
key:
from_secret: dev_ssh_key
script:
- echo "🚀 Deploying to dev.cannabrands.app..."
- cd /var/www/cannabrands
- docker compose pull app
- docker compose up -d app
- echo "⏳ Waiting for app to start..."
- sleep 5
- docker compose exec -T app php artisan migrate --force
- docker compose exec -T app php artisan config:cache
- 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.app"
when:
branch: master
event: push
status: success
# NOTIFY TEAM (optional)
notify-deployed:
image: curlimages/curl
commands:
- |
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "✅ DEV ENVIRONMENT UPDATED"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "URL: https://dev.cannabrands.app"
echo "Commit: ${CI_COMMIT_SHA:0:8}"
echo "Author: ${CI_COMMIT_AUTHOR}"
echo "Message: ${CI_COMMIT_MESSAGE}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
when:
branch: master
event: push
status: success
```
---
### Step 4: Create Dockerfile (if not already present)
**Create `Dockerfile` in project root:**
```dockerfile
FROM php:8.3-fpm
# Install system dependencies
RUN apt-get update && apt-get install -y \
git \
curl \
libpng-dev \
libonig-dev \
libxml2-dev \
libpq-dev \
zip \
unzip \
nodejs \
npm
# Install PHP extensions
RUN docker-php-ext-install pdo pdo_pgsql mbstring exif pcntl bcmath gd
# Install Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Set working directory
WORKDIR /var/www/html
# Copy application files
COPY . .
# Install PHP dependencies
RUN composer install --no-dev --optimize-autoloader --no-interaction
# Install and build frontend assets
RUN npm ci && npm run build
# Set permissions
RUN chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache
EXPOSE 8000
CMD ["php", "artisan", "serve", "--host=0.0.0.0", "--port=8000"]
```
---
## Git Workflow for Pre-Release Phase
### Current Approach: Single Branch (Acceptable Now)
```
feature work → commit directly to master → auto-deploy to dev
```
**When this works:**
- Solo developer or 2-person team
- Pre-launch phase
- Fast iteration needed
- No customers affected
**When to stop:**
- Once you have your first paying customer
- When team grows to 3+ developers
- When you need more stability
---
### Transitioning to Feature Branches (Recommended Soon)
**When to implement:** Before first customer OR when 3+ developers
```
feature/new-thing → PR → master (after review) → auto-deploy to dev
```
**Benefits:**
- Code review before merging
- Catch issues before deployment
- Better collaboration
- Cleaner history
**How to transition:**
```bash
# Instead of committing to master:
git checkout -b feature/add-payment-terms
# ... make changes ...
git add .
git commit -m "feat: add payment term surcharge logic"
git push origin feature/add-payment-terms
# Create PR in Gitea
# After approval, merge to master
# Master auto-deploys to dev
```
---
## Team Testing Guide
**Create `TESTING.md` in project root:**
```markdown
# Testing the Cannabrands App
## Dev Environment
**URL:** https://dev.cannabrands.app
**Status:** 🟡 Active Development
- Updates automatically on every master push
- Expect bugs and incomplete features
- Data may be reset without notice
## Test Accounts
### Buyer (Dispensary)
- Email: `dispensary@example.com`
- Password: `password`
- Use this to test: browsing marketplace, placing orders, viewing invoices
### Seller (Brand)
- Email: `brand@example.com`
- Password: `password`
- Use this to test: managing products, viewing orders, creating manifests
### Admin
- Email: `admin@example.com`
- Password: `password`
- Use this to test: user approval, platform management
## Reporting Issues
When you find a bug:
1. **Check if it's already reported** - Look at existing GitHub issues
2. **Reproduce the issue** - Try to make it happen again
3. **Document steps** - Write down exactly what you did
4. **Take screenshots** - Visual proof helps
5. **Create issue** - Use template below
### Issue Template
```
**Title:** [Brief description of bug]
**Steps to Reproduce:**
1. Go to '...'
2. Click on '...'
3. See error
**Expected Behavior:**
What should have happened
**Actual Behavior:**
What actually happened
**Screenshots:**
[Attach screenshots]
**Environment:**
- Browser: Chrome/Firefox/Safari
- Device: Desktop/Mobile
```
## Known Issues
- [ ] Invoice generation may be slow
- [ ] Mobile layout needs work
- [ ] Email notifications not yet implemented
## What to Focus On
**High Priority Testing:**
1. ✅ Can buyers place orders?
2. ✅ Can sellers view and approve orders?
3. ✅ Do invoices calculate correctly?
4. ✅ Can manifests be generated?
**Nice to Test:**
- User registration flow
- Password reset
- Profile updates
- Product filtering/search
## Support
Questions? Ask in #development Slack channel or email dev@cannabrands.com
```
---
## Transitioning to Production
### When to Move Beyond Pre-Release
**Indicators you're ready:**
- ✅ First paying customer signed
- ✅ Core workflows are stable
- ✅ Team has grown to 3+ people
- ✅ Need for staged deployments
**What changes:**
1. **Rename environments:**
- `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:**
- Feature branches → dev (auto-deploy)
- Master → staging (auto-deploy)
- Manual promotion staging → production
3. **Add compliance:**
- Deployment checklists
- Audit logging
- Manual approval gates
**See:** `CI_CD_STRATEGIES.md` for full production strategy
---
## Cost Considerations
### Pre-Release Phase (Minimal Costs)
**Single Dev Server:**
- $20-40/month (DigitalOcean/Linode/Hetzner 4GB RAM)
- Runs both app and database
- Good for 5-10 concurrent testers
**When to upgrade:**
- More than 10 concurrent users
- Performance becomes noticeable
- Before first customer
---
## Alignment with CI_CD_STRATEGIES.md
**This document covers:** Pre-Release Phase (Phase 0)
- Before you have customers
- Master branch = active development
- Single dev environment
- Fast iteration
**CI_CD_STRATEGIES.md covers:** Post-Release Phases
- Phase 1: CI only (you completed this ✅)
- Phase 2: Continuous Delivery (after first customer)
- Phase 3: Environment Promotion (when team grows)
**Transition point:** First paying customer OR team grows to 5+ people
---
## Quick Reference Commands
### Deploy Manually (if CI fails)
```bash
# SSH into dev server
ssh deployer@dev.cannabrands.app
# Navigate to app directory
cd /var/www/cannabrands
# Pull latest image
docker compose pull app
# Restart services
docker compose up -d app
# Run migrations
docker compose exec app php artisan migrate --force
# Clear caches
docker compose exec app php artisan config:cache
docker compose exec app php artisan route:cache
docker compose exec app php artisan view:cache
```
### Check Deployment Status
```bash
# View logs
docker compose logs -f app
# Check running containers
docker compose ps
# View recent deployments
docker images | grep cannabrands
```
### Rollback (if deployment breaks)
```bash
# Pull previous commit's image
docker pull code.cannabrands.app/cannabrands/hub:PREVIOUS_SHA
# Update docker-compose.yml to use specific tag
docker compose up -d app
```
### Reset Database (fresh start)
```bash
# ⚠️ This deletes ALL data
docker compose down -v
docker compose up -d
docker compose exec app php artisan migrate --seed
```
---
## Summary
**Your current setup:**
- ✅ CI tests pass on every push
- ⬜ Build Docker images (add this)
- ⬜ Auto-deploy to dev.cannabrands.app (add this)
**What this enables:**
- Teammates can test at https://dev.cannabrands.app
- Always reflects latest master
- Fast iteration (deploy in ~2 minutes)
- No manual deployment needed
**When to evolve:**
- First customer → add staging + production
- See CI_CD_STRATEGIES.md for next phases
**Key principle for pre-release:**
*"Move fast and iterate. Stability comes later, when customers depend on you."*

View File

@@ -1,441 +1,165 @@
# Quick Reference Guide - Cannabrands Release Workflow
# Cannabrands CI/CD Pipeline
**Print this and keep it handy!**
## Quick Reference
---
**2-Environment Workflow** optimized for small teams:
## Daily Development (What You'll Do 95% of the Time)
```
localhost (Sail) → push to develop → dev.cannabrands.app
PR: develop → master
merge → cannabrands.app (production)
```
### Making Changes
## Daily Development
### Push to Develop (Auto-deploys to dev)
```bash
# 1. Pull latest
git checkout master
git pull origin master
# 2. Make changes, commit with conventional format
git checkout develop
git pull origin develop
# make changes
git add .
git commit -m "feat(orders): add bulk import feature"
git push origin master
# ✅ DONE - CI automatically builds dev image
git commit -m "feat(orders): add bulk import"
git push origin develop
# ✅ Auto-deploys to dev.cannabrands.app in ~4-5 minutes
```
### Conventional Commit Format
### Deploy to Production
**Format:** `type(scope): description`
**Types:**
- `feat:` - New feature
- `fix:` - Bug fix
- `docs:` - Documentation only
- `style:` - Code style (formatting)
- `refactor:` - Code refactoring
- `test:` - Adding tests
- `chore:` - Build/dependencies
**Examples:**
```bash
git commit -m "feat(orders): add CSV bulk import"
git commit -m "fix(invoices): correct CA tax calculation"
git commit -m "docs: update deployment guide"
git commit -m "refactor(auth): simplify login flow"
# 1. Test on dev.cannabrands.app first
# 2. Open PR: develop → master
# 3. Wait for CI checks to pass
# 4. Click "Merge" in Gitea
# ✅ Auto-deploys to cannabrands.app
```
---
## Pipeline Stages
## Creating a Release (Weekly/Monthly)
| Event | What Runs | Time |
|-------|-----------|------|
| Push to `develop` | lint ∥ style → tests → seeders → build → deploy dev | ~4-5 min |
| PR `develop → master` | lint ∥ style → tests | ~2-3 min |
| Merge to `master` | build → deploy production | ~2-3 min |
| Tag (e.g., `2025.12.1`) | build versioned release | ~2 min |
### Step 1: Determine Version Number
**Key optimizations:**
- Pre-built CI image with PHP extensions (saves ~60-90s)
- Parallel lint + code-style checks
- Tests run once (on PR), skipped on merge
## Commit Message Format
```bash
# What's the current month and year?
# Today: November 2025
# Check existing releases this month
git tag -l "2025.11.*" | sort -V | tail -1
# Output: 2025.11.2
# Next version: 2025.11.3
feat(scope): description # New feature
fix(scope): description # Bug fix
docs: description # Documentation
refactor(scope): desc # Code refactoring
test(scope): description # Adding tests
chore: description # Build/dependencies
```
**Format:** `YYYY.MM.MICRO`
- `2025` = Year
- `11` = Month (November)
- `3` = Third release this month
**Scopes:** orders, invoices, auth, products, checkout, brands, etc.
### Step 2: Create Git Tag
## Rollback
### Quick Rollback (Production)
```bash
# Create annotated tag with release notes
git tag -a 2025.11.3 -m "Release 2025.11.3 - Bulk Import Feature
Features:
- Added CSV bulk order import
- Enhanced manifest generation
Bug Fixes:
- Fixed invoice tax calculation
- Corrected order status transitions
"
# Push tag to trigger CI
git push origin 2025.11.3
```
### Step 3: Wait for CI Build (2-4 minutes)
Watch at: `code.cannabrands.app/cannabrands/hub/pipelines`
CI will automatically:
- Run tests
- Build Docker image
- Tag as: `2025.11.3` and `stable`
- Push to registry
### Step 4: Generate Changelog
```bash
# Generate/update CHANGELOG.md from commits
npm run changelog
# Review the changes
cat CHANGELOG.md
# Commit the updated changelog
git add CHANGELOG.md
git commit -m "docs: update changelog for 2025.11.3"
git push origin master
```
### Step 5: Deploy to Production (When Ready)
```bash
# Deploy specific version
kubectl set image deployment/cannabrands \
app=code.cannabrands.app/cannabrands/hub:2025.11.3
# Watch deployment
kubectl rollout status deployment/cannabrands
# Verify
kubectl get pods
```
---
## Emergency Rollback
### Production is Broken - Immediate Action
```bash
# Option 1: Rollback to previous version
kubectl set image deployment/cannabrands \
app=code.cannabrands.app/cannabrands/hub:2025.11.2
# Option 1: Deploy previous commit
kubectl set image deployment/cannabrands-hub \
app=code.cannabrands.app/cannabrands/hub:prod-PREVIOUS_SHA \
migrate=code.cannabrands.app/cannabrands/hub:prod-PREVIOUS_SHA \
-n cannabrands-prod
# Option 2: Kubernetes automatic rollback
kubectl rollout undo deployment/cannabrands
# Verify rollback
kubectl rollout status deployment/cannabrands
kubectl rollout undo deployment/cannabrands-hub -n cannabrands-prod
```
### After Rollback - Fix Properly
### Find Previous SHA
```bash
# 1. Fix the bug on master
git commit -m "fix: invoice calculation regression"
git push origin master
# View recent deployments
kubectl rollout history deployment/cannabrands-hub -n cannabrands-prod
# 2. Test thoroughly in staging
# 3. Create new release
git tag -a 2025.11.4 -m "Hotfix: Invoice calculation"
git push origin 2025.11.4
# 4. Deploy when confident
kubectl set image deployment/cannabrands \
app=code.cannabrands.app/cannabrands/hub:2025.11.4
# View recent images
git log --oneline -10
```
---
## Environments
## Image Tags Explained
| Environment | URL | Branch | Namespace |
|-------------|-----|--------|-----------|
| Development | dev.cannabrands.app | develop | cannabrands-dev |
| Production | cannabrands.app | master | cannabrands-prod |
## Image Tags
| Tag | Description | Used By |
|-----|-------------|---------|
| `dev` | Latest develop build | Dev environment |
| `dev-{SHA}` | Specific dev commit | Dev deployments |
| `latest` | Latest production build | - |
| `prod-{SHA}` | Specific production commit | Prod deployments |
| `2025.X.Y` | Versioned release | Rollback reference |
## Pre-built CI Image
The pipeline uses a pre-built image with PHP extensions to speed up builds:
### Development Images (Automatic)
```
latest-dev → Always newest master
dev-c658193 → Specific commit (for debugging)
master → Branch tracking
code.cannabrands.app/cannabrands/ci-runner:latest
```
**Use in K3s dev/staging:**
```yaml
image: code.cannabrands.app/cannabrands/hub:latest-dev
imagePullPolicy: Always
```
### Rebuild CI Image (when PHP version changes)
### Production Images (Manual Release)
```
2025.11.3 → Specific release
stable → Latest production release
```
**Use in K3s production:**
```yaml
image: code.cannabrands.app/cannabrands/hub:2025.11.3
imagePullPolicy: IfNotPresent
```
---
## Common Commands
### Check Current Version
```bash
# What's deployed in production?
kubectl get deployment cannabrands -o jsonpath='{.spec.template.spec.containers[0].image}'
# What releases exist this month?
git tag -l "2025.11.*" | sort -V
cd docker/ci
docker build -t code.cannabrands.app/cannabrands/ci-runner:latest .
docker push code.cannabrands.app/cannabrands/ci-runner:latest
```
### Test Locally
## Secrets Required (Woodpecker)
| Secret | Description |
|--------|-------------|
| `gitea_username` | Gitea registry username |
| `gitea_token` | Gitea registry token |
| `kubeconfig_dev` | Base64-encoded kubeconfig for dev cluster |
| `kubeconfig_prod` | Base64-encoded kubeconfig for prod cluster |
## Troubleshooting
### Build Failing
```bash
# Run tests
# Run tests locally first
./vendor/bin/sail artisan test
# Check code style
./vendor/bin/pint --test
# Build Docker image locally
docker build -t cannabrands:test .
```
### View CI Status
```bash
# Visit Woodpecker
open https://code.cannabrands.app/cannabrands/hub/pipelines
# Or check latest build
# (Visit Gitea → Repository → Pipelines)
```
---
## Troubleshooting
### CI Build Failing
```bash
# Check Woodpecker logs
# Visit: code.cannabrands.app/cannabrands/hub/pipelines
# Run tests locally first
./vendor/bin/sail artisan test
# Fix issues, push again
git commit -m "fix: broken tests"
git push origin master
```
### Wrong Version Tagged
```bash
# Delete tag locally
git tag -d 2025.11.3
# Delete tag remotely
git push origin :refs/tags/2025.11.3
# Create correct tag
git tag -a 2025.11.3 -m "Release 2025.11.3"
git push origin 2025.11.3
```
### Changelog Not Generating
```bash
# Make sure you have conventional commits
git log --oneline | head -10
# Should see: feat:, fix:, docs:, etc.
# If missing, your commits need to follow convention
# Run changelog anyway
npm run changelog
```
---
## Versioning Examples
### Typical Month
```
2025.11.1 (Nov 5) - First release
2025.11.2 (Nov 12) - Bug fixes
2025.11.3 (Nov 19) - New features
2025.11.4 (Nov 26) - Hotfix
2025.12.1 (Dec 3) - New month, reset
```
### High Frequency (Multiple per day)
```
2025.11.23.1 - Morning release
2025.11.23.2 - Afternoon hotfix
2025.11.24.1 - Next day
```
### Skipping Numbers (OK!)
```
2025.11.1 ✅
2025.11.2 ✅
2025.11.5 ✅ (skipped 3 and 4 - fine!)
```
---
## CI/CD Pipeline Stages
The Woodpecker CI pipeline runs the following stages for every push to `develop` or `master`:
1. **PHP Lint** - Syntax validation
2. **Code Style (Pint)** - Formatting check
3. **Tests** - PHPUnit/Pest tests with `APP_ENV=testing`
4. **Seeder Validation** - Validates seeders with `APP_ENV=development`
5. **Docker Build** - Creates container image
6. **Auto-Deploy** - Deploys to dev.cannabrands.app (develop branch only)
### Why Seeder Validation?
The dev environment (`dev.cannabrands.app`) runs `migrate:fresh --seed` on every K8s deployment via init container. If seeders have bugs (e.g., undefined functions, missing relationships), the deployment fails and pods crash.
**The Problem:**
- Tests run with `APP_ENV=testing` which **skips DevSeeder**
- K8s runs with `APP_ENV=development` which **runs DevSeeder**
- Seeder bugs passed CI but crashed in K8s
**The Solution:**
- Add dedicated seeder validation step with `APP_ENV=development`
- Runs the exact same command as K8s init container
- Catches seeder errors before deployment
**Time Cost:** ~20-30 seconds added to CI pipeline
**What It Catches:**
- Runtime errors (e.g., `fake()` outside factory context)
- Database constraint violations
- Missing relationships (foreign key errors)
- Invalid enum values
- Seeder syntax errors
---
## Pre-Commit Checklist
Before committing:
- [ ] Tests pass locally (`./vendor/bin/sail artisan test`)
- [ ] Code formatted (`./vendor/bin/pint` runs automatically)
- [ ] Commit message follows convention (feat:, fix:, etc.)
Before releasing:
- [ ] All tests green in CI
- [ ] **Seeder validation passed in CI**
- [ ] Tested in dev/staging environment
- [ ] Release notes written
- [ ] CHANGELOG updated (auto-generated)
Before deploying:
- [ ] Tag created and pushed
- [ ] CI build successful
- [ ] Team notified
- [ ] Deployment window appropriate (not Friday night!)
---
## Getting Help
### Documentation
- `RELEASE_WORKFLOW_GUIDE.md` - Detailed release process
- `VERSIONING_STRATEGY.md` - CalVer strategy & rollback
- `GIT_BRANCHING_STRATEGY.md` - Git workflow
- `CI_CD_STRATEGIES.md` - Overall strategy
### Team
- Ask in #engineering Slack channel
- Pair with senior dev for first release
### CI/CD
- Woodpecker: `code.cannabrands.app/cannabrands/hub`
- Gitea: `code.cannabrands.app/cannabrands/hub`
- K3s Dashboard: (ask devops for link)
---
## Important URLs
**Code Repository:**
https://code.cannabrands.app/cannabrands/hub
**CI/CD Pipeline:**
https://code.cannabrands.app/cannabrands/hub/pipelines
**Container Registry:**
https://code.cannabrands.app/-/packages/container/cannabrands%2Fhub
**Documentation:**
`.woodpecker/` directory in repository
---
## Commit Message Cheat Sheet
### Deployment Stuck
```bash
# New feature
git commit -m "feat(scope): what you added"
# Check pod status
kubectl get pods -n cannabrands-dev
kubectl describe pod POD_NAME -n cannabrands-dev
# Bug fix
git commit -m "fix(scope): what you fixed"
# Documentation
git commit -m "docs: what you documented"
# Code cleanup
git commit -m "refactor(scope): what you refactored"
# Testing
git commit -m "test(scope): what you tested"
# Dependencies/config
git commit -m "chore: what you updated"
# Check logs
kubectl logs -f deployment/cannabrands-hub -n cannabrands-dev
```
**Scope examples:** orders, invoices, auth, products, checkout
### Seeder Issues in Dev
**Full example:**
```bash
git commit -m "feat(orders): add CSV bulk import
Allows sellers to import multiple orders from CSV file.
Includes validation and preview before import.
Closes #42"
```
The pipeline validates seeders before deploying to dev. If seeders fail:
1. Check the CI logs for the specific error
2. Fix the seeder locally
3. Push again
---
## One-Page Summary
| Task | Command |
|------|---------|
| Daily commit | `git commit -m "feat(scope): description"` |
| Create release | `git tag -a 2025.11.1 -m "notes"` |
| Update changelog | `npm run changelog` |
| 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.app/cannabrands/hub/pipelines` |
---
**Key Principle:** *Commit often, release when ready, rollback without fear.*
**Version:** 1.0
**Last Updated:** 2025-10-23
**Print and keep handy!**
**Last Updated:** December 2025

View File

@@ -1,817 +0,0 @@
# Release Workflow Guide for Cannabrands Team
## Purpose
This guide explains our release workflow, why we made these choices, and how to execute releases. Use this for onboarding new team members and as a reference.
---
## Our Release Philosophy
**Core Principle:** *"Automate the mechanical, preserve human judgment."*
- ✅ Automated: Tests, builds, image creation
- 🤔 Human decision: When to release, what to include
- ⚡ Fast: Push to master → deployed to dev in ~3 minutes
---
## Two Tracks: Development vs Production
### Track 1: Development (Automatic - Daily)
**Who uses it:** Internal team testing
**What happens:**
```bash
# You do this:
git commit -m "feat: add bulk order import"
git push origin master
# Automatic chain reaction:
1. Woodpecker CI triggers (immediate)
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.app/cannabrands/hub
6. Available in K3s dev namespace (manual or auto-pull)
```
**Timeline:** 2-4 minutes from push to available
**Tags created:**
- `latest-dev` - Always points to newest master
- `dev-c658193` - Specific commit for debugging
- `master` - Branch tracking
**Use in K3s:**
```yaml
# dev/staging namespace
image: code.cannabrands.app/cannabrands/hub:latest-dev
imagePullPolicy: Always # Always pull newest
```
---
### Track 2: Production (Manual Decision - Weekly/Monthly)
**Who uses it:** Customers, production environment
**What happens:**
```bash
# You decide to create a release:
git tag -a 2025.11.1 -m "Release 2025.11.1 - Invoice improvements"
git push origin 2025.11.1
# Automatic chain reaction:
1. Woodpecker CI triggers (immediate)
2. Tests run (same as dev)
3. Docker image builds (if tests pass)
4. Tagged as: 2025.11.1, stable
5. Pushed to registry
6. You deploy when ready (manual kubectl command)
```
**Timeline:** 2-4 minutes to build, deploy when you choose
**Tags created:**
- `2025.11.1` - Specific release version
- `stable` - Always points to latest production release
**Use in K3s:**
```yaml
# production namespace
image: code.cannabrands.app/cannabrands/hub:2025.11.1
imagePullPolicy: IfNotPresent # Pin to specific version
```
---
## Calendar Versioning (CalVer) Explained
### Format: YYYY.MM.MICRO
**Example:** `2025.11.1`
- **2025** = Year
- **11** = Month (November)
- **1** = First release in November
**Why CalVer instead of SemVer (v1.2.3)?**
| CalVer (2025.11.1) | SemVer (v1.2.3) |
|--------------------|-----------------|
| ✅ Shows WHEN released | ❌ Doesn't show time |
| ✅ Natural chronological order | ⚠️ Can be confusing (v2.0 vs v1.9?) |
| ✅ Great for compliance/audits | ❌ Less useful for auditors |
| ✅ No debates ("major or minor?") | ❌ Constant debates |
| ✅ Web app model (you control both ends) | ✅ Library model (public API) |
**Companies using CalVer:**
- Ubuntu (20.04, 22.04)
- Puppet (2023.1)
- PyCharm (2025.3)
- Cal.com (app for scheduling)
**Companies using SemVer:**
- NPM packages
- Laravel framework
- React library
**Rule of thumb:**
- Web apps (you) → CalVer
- Libraries/APIs → SemVer
---
## Version Number Examples
### Realistic Timeline
```
2025.11.1 (Nov 5) - First customer release
2025.11.2 (Nov 12) - Bug fixes
2025.11.3 (Nov 19) - New feature (bulk import)
2025.11.4 (Nov 26) - Hotfix (invoice bug)
2025.12.1 (Dec 3) - Month rollover, new features
2025.12.2 (Dec 10) - Bug fixes
```
### Alternative for High Frequency
If you release multiple times per day:
```
2025.11.23.1 - First release on Nov 23
2025.11.23.2 - Hotfix same day
2025.11.24.1 - Next day
```
**Recommendation:** Start with `YYYY.MM.MICRO`, switch if needed.
---
## Daily Workflow (Development)
### For Developers
**Morning:**
```bash
git checkout master
git pull origin master
git checkout -b feature/add-payment-terms
# Make changes...
git add .
git commit -m "feat: add payment term surcharges"
git push origin feature/add-payment-terms
# Create PR in Gitea (optional for now)
# After approval (or if working solo):
git checkout master
git merge feature/add-payment-terms
git push origin master
# ✅ DONE - Automatic CI builds dev image
```
**What happens automatically:**
1. Woodpecker runs tests
2. Builds Docker image
3. Pushes to registry
4. Team can test in K3s dev environment
**No manual steps for releases** - this is daily development.
---
## Release Workflow (Production)
### When to Create a Release
**Triggers for creating a release:**
- ✅ First customer signs up (big milestone)
- ✅ Weekly release schedule (every Monday?)
- ✅ Major feature complete and tested
- ✅ Critical bug fixed (hotfix)
- ✅ Compliance audit scheduled (need frozen version)
- ✅ Before regulatory inspection
**DON'T create releases for:**
- ❌ Every commit to master
- ❌ Incomplete features
- ❌ Untested code
- ❌ "Just to have a version"
---
### Step-by-Step: Creating a Release
#### 1. Ensure Code is Stable
```bash
# Verify tests pass locally
./vendor/bin/sail artisan test
# Check CI is green
# Visit: code.cannabrands.app/cannabrands/hub/pipelines
# Test in staging/dev environment
# Verify key workflows work
```
#### 2. Determine Version Number
**Current month and release:**
```bash
# What's the current version?
git tag -l "2025.11.*" | sort -V | tail -1
# Output: 2025.11.2
# Next version is 2025.11.3
```
**New month:**
```bash
# It's now December, start fresh
# Next version: 2025.12.1
```
#### 3. Create Git Tag
```bash
# Format: YYYY.MM.MICRO
git tag -a 2025.11.3 -m "Release 2025.11.3 - Bulk order import
Features:
- Added CSV bulk order import
- Enhanced manifest generation
Bug Fixes:
- Fixed invoice tax calculation
- Corrected order status transitions
Testing:
- Tested with 50+ orders
- Verified in staging environment
"
# Push tag to trigger CI
git push origin 2025.11.3
```
**Note:** The `-m` message becomes your release notes. Be descriptive!
#### 4. Monitor CI Build
```bash
# Watch Woodpecker build
# Visit: code.cannabrands.app/cannabrands/hub/pipelines
# Wait for success (2-4 minutes)
# CI will build and push:
# - code.cannabrands.app/cannabrands/hub:2025.11.3
# - code.cannabrands.app/cannabrands/hub:stable
```
#### 5. Deploy to Production (When Ready)
```bash
# Deploy new version
kubectl set image deployment/cannabrands \
app=code.cannabrands.app/cannabrands/hub:2025.11.3
# Watch rollout
kubectl rollout status deployment/cannabrands
# Verify deployment
kubectl get pods
kubectl logs -f deployment/cannabrands
```
#### 6. Update CHANGELOG (Optional but Recommended)
```bash
# Edit CHANGELOG.md
nano CHANGELOG.md
# Add entry:
## [2025.11.3] - 2025-11-19
### Added
- CSV bulk order import feature
- Enhanced manifest generation with audit trail
### Fixed
- Invoice tax calculation for CA orders
- Order status transition for pending approvals
# Commit and push
git add CHANGELOG.md
git commit -m "docs: update changelog for 2025.11.3"
git push origin master
```
---
## Rollback Procedure (Production Issues)
### Scenario: New Release is Broken
**Timeline:**
```
2:00pm - Deploy 2025.11.3
2:15pm - Customer reports invoices broken
2:16pm - Confirm issue
2:17pm - ⚠️ DECISION POINT
```
### Immediate Rollback (2 minutes)
```bash
# Option 1: Rollback to specific version
kubectl set image deployment/cannabrands \
app=code.cannabrands.app/cannabrands/hub:2025.11.2
# Option 2: Use previous stable
kubectl set image deployment/cannabrands \
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
# Use specific version (2025.11.2) for rollback
# Option 3: Kubernetes automatic rollback
kubectl rollout undo deployment/cannabrands
# Verify rollback
kubectl rollout status deployment/cannabrands
```
### Fix Forward (Proper Fix)
```bash
# After rollback, service is restored
# Now fix the issue properly:
1. Investigate root cause (no rush)
2. Fix bug on master
3. Test thoroughly in dev/staging
4. Create new release: 2025.11.4
5. Deploy when confident
git commit -m "fix: invoice calculation regression"
git push origin master
# Test in staging
# When ready:
git tag -a 2025.11.4 -m "Hotfix: Invoice calculation"
git push origin 2025.11.4
# Deploy
kubectl set image deployment/cannabrands \
app=code.cannabrands.app/cannabrands/hub:2025.11.4
```
---
## Automation Options
### Current State: Manual Tagging (Recommended)
**What's automated:**
- ✅ CI tests
- ✅ Docker image building
- ✅ Image pushing to registry
- ✅ Pre-commit code formatting
**What's manual:**
- 🤔 Deciding when to release
- 🤔 Creating git tags
- 🤔 Writing release notes
- 🤔 Deploying to production
**Why keep it manual?**
- You make the decision when code is "ready"
- Preserves human judgment
- Industry standard for companies your size
- Cannabis compliance (audit trail of decisions)
---
### Option: Add Auto-Changelog
**What it does:** Automatically generates CHANGELOG.md from commits
**Install:**
```bash
npm install -g conventional-changelog-cli
```
**Usage:**
```bash
# After creating release tag
conventional-changelog -p angular -i CHANGELOG.md -s
# Then commit the updated CHANGELOG
git add CHANGELOG.md
git commit -m "docs: update changelog"
git push origin master
```
**Or automate in CI:**
```yaml
# .woodpecker/.ci.yml
changelog:
image: node:20
commands:
- npm install -g conventional-changelog-cli
- conventional-changelog -p angular -i CHANGELOG.md -s -r 0
when:
event: tag
```
**Pros:**
- ✅ Automatic changelog generation
- ✅ Enforces commit message format
- ✅ No manual CHANGELOG maintenance
**Cons:**
- ⚠️ Requires conventional commits (feat:, fix:, etc.)
- ⚠️ Can be noisy if commits aren't clean
---
### Option: Automated CalVer Tagging (Advanced)
**What it does:** Automatically creates CalVer tags on merge to master
**Implementation:**
```yaml
# .woodpecker/.auto-release.yml
when:
event: push
branch: master
steps:
auto-tag:
image: alpine/git
commands:
- apk add --no-cache bash coreutils
- |
# Generate CalVer tag
YEAR=$(date +%Y)
MONTH=$(date +%-m)
# Find highest MICRO for this month
LATEST=$(git tag -l "${YEAR}.${MONTH}.*" | sort -V | tail -1)
if [ -z "$LATEST" ]; then
MICRO=1
else
MICRO=$(echo $LATEST | cut -d. -f3)
MICRO=$((MICRO + 1))
fi
TAG="${YEAR}.${MONTH}.${MICRO}"
# Create tag
git tag -a $TAG -m "Auto-release $TAG"
git push origin $TAG
```
**Result:** Every push to master automatically creates a release.
**Pros:**
- ✅ Fully automated releases
- ✅ No manual tagging needed
- ✅ Consistent versioning
**Cons:**
- ❌ No human judgment (every commit becomes a release)
- ❌ Can't skip a bad release easily
- ❌ Loses audit trail of "we decided to release this"
**Recommendation:** **DON'T use this** (yet). Wait until:
- You have 100+ deployments per month
- You need to release multiple times per day
- You have comprehensive test coverage (80%+)
---
## Recommended Automation (Add Now)
### Add Auto-Changelog Generation
**Step 1: Install conventional-changelog**
```bash
npm install --save-dev conventional-changelog-cli
```
**Step 2: Add to package.json**
```json
{
"scripts": {
"changelog": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0"
}
}
```
**Step 3: Update your release workflow**
```bash
# After creating git tag:
git push origin 2025.11.3
# Generate changelog
npm run changelog
# Commit updated changelog
git add CHANGELOG.md
git commit -m "docs: update changelog for 2025.11.3"
git push origin master
```
**Step 4: (Optional) Automate in CI**
```yaml
# Add to .woodpecker/.ci.yml
update-changelog:
image: node:20
commands:
- npm ci
- npm run changelog
- |
if ! git diff --quiet CHANGELOG.md; then
git config user.name "Woodpecker CI"
git config user.email "ci@cannabrands.com"
git add CHANGELOG.md
git commit -m "docs: update changelog for ${CI_COMMIT_TAG}"
git push origin master
fi
when:
event: tag
```
---
## Commit Message Convention
For auto-changelog to work well, use conventional commits:
### Format
```
<type>(<scope>): <subject>
<body>
<footer>
```
### Types
- `feat`: New feature
- `fix`: Bug fix
- `docs`: Documentation only
- `style`: Code style (formatting, no logic change)
- `refactor`: Code refactoring
- `test`: Adding tests
- `chore`: Build process, dependencies
### Examples
**Good:**
```bash
git commit -m "feat(orders): add CSV bulk import
Allows sellers to import multiple orders at once from CSV file.
Validates data before import and shows preview.
Closes #42"
```
```bash
git commit -m "fix(invoices): correct tax calculation for CA
California orders were calculating sales tax incorrectly.
Now properly applies 7.25% base rate + local rates.
Fixes #123"
```
```bash
git commit -m "docs: update deployment guide for K3s"
```
**Bad (won't work well with auto-changelog):**
```bash
git commit -m "fixed stuff"
git commit -m "updates"
git commit -m "wip"
```
---
## Team Training Checklist
Use this to onboard new developers:
### Day 1: Understanding the Flow
- [ ] Read this document
- [ ] Understand dev vs production tracks
- [ ] Learn CalVer format (YYYY.MM.MICRO)
- [ ] Review commit message conventions
### Week 1: Development Flow
- [ ] Make changes on feature branch
- [ ] Push to master (or create PR)
- [ ] Watch Woodpecker CI run
- [ ] See dev image appear in registry
- [ ] Test in K3s dev environment
### Week 2: Release Flow (Shadow Senior Dev)
- [ ] Watch senior dev create a release
- [ ] Understand tag creation process
- [ ] See production deployment
- [ ] Learn rollback procedure
### Week 3: First Release (Supervised)
- [ ] Create your first release tag (with supervision)
- [ ] Write release notes
- [ ] Deploy to staging/production
- [ ] Update CHANGELOG
### Week 4: Independent
- [ ] Create releases independently
- [ ] Handle rollbacks if needed
- [ ] Mentor new team members
---
## FAQ
### Q: How often should we release?
**A:** No fixed rule, but typical patterns:
**Pre-first customer (now):**
- Releases: When needed (weekly-ish)
- Dev builds: Constantly (every push)
**1-10 customers:**
- Releases: Weekly or bi-weekly
- Schedule: Every Monday morning
**10+ customers:**
- Releases: Multiple per week
- Schedule: As features are ready
**High-growth:**
- Releases: Daily
- May add automated tagging
---
### Q: What if we skip a version number?
**A:** No problem!
```
2025.11.1 ✅
2025.11.2 ✅
2025.11.5 ✅ (skipped 3 and 4 - fine!)
```
Version numbers are not sacred. If you want to skip, skip.
---
### Q: Can we have multiple releases per day?
**A:** Yes! Use extended format:
```
2025.11.23.1 - Morning release
2025.11.23.2 - Afternoon hotfix
2025.11.24.1 - Next day
```
Or just increment MICRO:
```
2025.11.1
2025.11.2
2025.11.3
(all same day - fine!)
```
---
### Q: What if CI fails on a tag?
**A:** Delete and recreate:
```bash
# Delete local tag
git tag -d 2025.11.3
# Delete remote tag
git push origin :refs/tags/2025.11.3
# Fix the issue
git commit -m "fix: issue that broke CI"
git push origin master
# Create tag again
git tag -a 2025.11.3 -m "Release 2025.11.3"
git push origin 2025.11.3
```
---
### Q: Should we delete old Docker images?
**A:** Keep recent releases, clean old dev builds:
**Keep forever:**
- All production releases (2025.11.1, 2025.11.2, etc.)
- Last 10 releases minimum
**Clean periodically:**
- Dev builds older than 30 days
- Failed/broken releases (after investigation)
**Why keep old releases:**
- Compliance audit trail
- Rollback capability
- Forensic analysis of bugs
---
### Q: Can we automate production deployment?
**A:** Not recommended for cannabis industry:
**Why NOT automate:**
- Regulatory compliance requires human approval
- Need audit trail of who deployed what
- Cannabis industry = higher stakes
- Manual gate = safer
**What to automate instead:**
- Deployment to dev/staging
- Tests and builds
- Release notes generation
---
## Summary: What to Remember
**Daily work:**
```bash
# Just push to master
git push origin master
# Everything else is automatic
```
**Creating releases (weekly/monthly):**
```bash
# 1. Create tag
git tag -a 2025.11.3 -m "Release notes"
git push origin 2025.11.3
# 2. Wait for CI (2-4 min)
# 3. Deploy when ready
kubectl set image deployment/cannabrands app=...:2025.11.3
```
**Emergency rollback:**
```bash
# One command
kubectl set image deployment/cannabrands app=...:2025.11.2
```
**Key principles:**
- ✅ Automate the mechanical (tests, builds)
- 🤔 Preserve human judgment (releases, deployments)
- 📅 Use CalVer for timestamp-based versions
- 🔄 Rollback first, fix later
- 📝 Document decisions (release notes, CHANGELOG)
---
## Next Steps
**Implement now:**
1. ✅ Keep current manual tagging (it's working)
2. ✅ Add auto-changelog generation
3. ✅ Create CHANGELOG.md file
**Consider later (when needed):**
1. Automated tagging (if you release daily)
2. Feature flags (for gradual rollouts)
3. Automated staging deployments
**Never compromise:**
1. Manual production deployment (compliance)
2. Human approval for releases (judgment)
3. Audit trail (cannabis industry requirement)
---
**Version:** 1.0
**Last Updated:** 2025-10-23
**Maintained By:** Engineering Team

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Buyer\Crm;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Buyer\BuyerMessageSettings;
use App\Models\Buyer\BuyerNotificationSettings;
use Illuminate\Http\Request;
@@ -10,30 +11,27 @@ use Illuminate\Support\Facades\Auth;
class SettingsController extends Controller
{
public function index()
public function index(Request $request, Business $business)
{
$business = Auth::user()->business;
$user = Auth::user();
$messageSettings = BuyerMessageSettings::getOrCreate($business->id, $user->id);
$notificationSettings = BuyerNotificationSettings::getOrCreate($business->id, $user->id);
return view('buyer.crm.settings.index', compact('messageSettings', 'notificationSettings'));
return view('buyer.crm.settings.index', compact('business', 'messageSettings', 'notificationSettings'));
}
public function notifications()
public function notifications(Request $request, Business $business)
{
$business = Auth::user()->business;
$user = Auth::user();
$settings = BuyerNotificationSettings::getOrCreate($business->id, $user->id);
return view('buyer.crm.settings.notifications', compact('settings'));
return view('buyer.crm.settings.notifications', compact('business', 'settings'));
}
public function updateNotifications(Request $request)
public function updateNotifications(Request $request, Business $business)
{
$business = Auth::user()->business;
$user = Auth::user();
$validated = $request->validate([
@@ -84,9 +82,8 @@ class SettingsController extends Controller
return back()->with('success', 'Notification preferences updated.');
}
public function messages()
public function messages(Request $request, Business $business)
{
$business = Auth::user()->business;
$user = Auth::user();
$settings = BuyerMessageSettings::getOrCreate($business->id, $user->id);
@@ -97,12 +94,11 @@ class SettingsController extends Controller
->with('brand')
->get();
return view('buyer.crm.settings.messages', compact('settings', 'followedBrands'));
return view('buyer.crm.settings.messages', compact('business', 'settings', 'followedBrands'));
}
public function updateMessages(Request $request)
public function updateMessages(Request $request, Business $business)
{
$business = Auth::user()->business;
$user = Auth::user();
$validated = $request->validate([
@@ -133,13 +129,12 @@ class SettingsController extends Controller
return back()->with('success', 'Message settings updated.');
}
public function muteBrand(Request $request)
public function muteBrand(Request $request, Business $business)
{
$validated = $request->validate([
'brand_id' => 'required|exists:brands,id',
]);
$business = Auth::user()->business;
$user = Auth::user();
$settings = BuyerMessageSettings::getOrCreate($business->id, $user->id);
@@ -152,13 +147,12 @@ class SettingsController extends Controller
return back()->with('success', 'Brand muted.');
}
public function unmuteBrand(Request $request)
public function unmuteBrand(Request $request, Business $business)
{
$validated = $request->validate([
'brand_id' => 'required|exists:brands,id',
]);
$business = Auth::user()->business;
$user = Auth::user();
$settings = BuyerMessageSettings::getOrCreate($business->id, $user->id);
@@ -171,15 +165,14 @@ class SettingsController extends Controller
return back()->with('success', 'Brand unmuted.');
}
public function account()
public function account(Request $request, Business $business)
{
$user = Auth::user();
$business = $user->business;
return view('buyer.crm.settings.account', compact('user', 'business'));
}
public function updateAccount(Request $request)
public function updateAccount(Request $request, Business $business)
{
$user = Auth::user();
@@ -195,7 +188,7 @@ class SettingsController extends Controller
return back()->with('success', 'Account updated.');
}
public function updatePassword(Request $request)
public function updatePassword(Request $request, Business $business)
{
$validated = $request->validate([
'current_password' => 'required|current_password',

View File

@@ -22,6 +22,12 @@ class CustomerController extends Controller
'business' => $business,
'feature' => 'Customers',
'description' => 'The Customers feature requires CRM to be enabled for your business.',
'benefits' => [
'Manage all your customer accounts in one place',
'Track contact information and order history',
'Build stronger customer relationships',
'Access customer insights and analytics',
],
]);
}
@@ -42,6 +48,12 @@ class CustomerController extends Controller
'business' => $business,
'feature' => 'Customers',
'description' => 'The Customers feature requires CRM to be enabled for your business.',
'benefits' => [
'Manage all your customer accounts in one place',
'Track contact information and order history',
'Build stronger customer relationships',
'Access customer insights and analytics',
],
]);
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Seller\Crm;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Crm\CrmAutomation;
use App\Models\Crm\CrmAutomationAction;
use App\Models\Crm\CrmAutomationCondition;
@@ -13,10 +14,8 @@ class AutomationController extends Controller
/**
* List automations
*/
public function index(Request $request)
public function index(Request $request, Business $business)
{
$business = $request->user()->business;
$automations = CrmAutomation::forBusiness($business->id)
->with('creator')
->withCount(['logs as successful_runs' => fn ($q) => $q->completed()])
@@ -29,7 +28,7 @@ class AutomationController extends Controller
/**
* Show automation builder
*/
public function create(Request $request)
public function create(Request $request, Business $business)
{
$triggers = CrmAutomation::TRIGGERS;
$operators = CrmAutomationCondition::OPERATORS;
@@ -41,10 +40,8 @@ class AutomationController extends Controller
/**
* Store automation
*/
public function store(Request $request)
public function store(Request $request, Business $business)
{
$business = $request->user()->business;
$validated = $request->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string|max:1000',
@@ -100,17 +97,15 @@ class AutomationController extends Controller
]);
}
return redirect()->route('seller.crm.automations.show', $automation)
return redirect()->route('seller.business.crm.automations.show', [$business, $automation])
->with('success', 'Automation created successfully.');
}
/**
* Show automation details
*/
public function show(Request $request, CrmAutomation $automation)
public function show(Request $request, Business $business, CrmAutomation $automation)
{
$business = $request->user()->business;
if ($automation->business_id !== $business->id) {
abort(404);
}
@@ -123,10 +118,8 @@ class AutomationController extends Controller
/**
* Edit automation
*/
public function edit(Request $request, CrmAutomation $automation)
public function edit(Request $request, Business $business, CrmAutomation $automation)
{
$business = $request->user()->business;
if ($automation->business_id !== $business->id) {
abort(404);
}
@@ -143,10 +136,8 @@ class AutomationController extends Controller
/**
* Update automation
*/
public function update(Request $request, CrmAutomation $automation)
public function update(Request $request, Business $business, CrmAutomation $automation)
{
$business = $request->user()->business;
if ($automation->business_id !== $business->id) {
abort(404);
}
@@ -211,17 +202,15 @@ class AutomationController extends Controller
]);
}
return redirect()->route('seller.crm.automations.show', $automation)
return redirect()->route('seller.business.crm.automations.show', [$business, $automation])
->with('success', 'Automation updated successfully.');
}
/**
* Toggle automation active status
*/
public function toggle(Request $request, CrmAutomation $automation)
public function toggle(Request $request, Business $business, CrmAutomation $automation)
{
$business = $request->user()->business;
if ($automation->business_id !== $business->id) {
abort(404);
}
@@ -238,34 +227,30 @@ class AutomationController extends Controller
/**
* Duplicate automation
*/
public function duplicate(Request $request, CrmAutomation $automation)
public function duplicate(Request $request, Business $business, CrmAutomation $automation)
{
$business = $request->user()->business;
if ($automation->business_id !== $business->id) {
abort(404);
}
$copy = $automation->duplicate();
return redirect()->route('seller.crm.automations.edit', $copy)
return redirect()->route('seller.business.crm.automations.edit', [$business, $copy])
->with('success', 'Automation duplicated. Make your changes and activate when ready.');
}
/**
* Delete automation
*/
public function destroy(Request $request, CrmAutomation $automation)
public function destroy(Request $request, Business $business, CrmAutomation $automation)
{
$business = $request->user()->business;
if ($automation->business_id !== $business->id) {
abort(404);
}
$automation->delete();
return redirect()->route('seller.crm.automations.index')
return redirect()->route('seller.business.crm.automations.index', $business)
->with('success', 'Automation deleted.');
}
}

View File

@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Seller\Crm;
use App\Http\Controllers\Controller;
use App\Jobs\Crm\SyncCalendarJob;
use App\Models\Business;
use App\Models\Crm\CrmCalendarConnection;
use App\Models\Crm\CrmSyncedEvent;
use App\Services\Crm\CrmCalendarService;
@@ -18,9 +19,8 @@ class CrmCalendarController extends Controller
/**
* Calendar view
*/
public function index(Request $request)
public function index(Request $request, Business $business)
{
$business = $request->user()->business;
$user = $request->user();
// Get calendar connections
@@ -78,9 +78,8 @@ class CrmCalendarController extends Controller
/**
* Calendar connections settings
*/
public function connections(Request $request)
public function connections(Request $request, Business $business)
{
$business = $request->user()->business;
$user = $request->user();
$connections = CrmCalendarConnection::where('business_id', $business->id)
@@ -93,17 +92,17 @@ class CrmCalendarController extends Controller
/**
* Start OAuth flow for Google Calendar
*/
public function connectGoogle(Request $request)
public function connectGoogle(Request $request, Business $business)
{
$state = encrypt([
'user_id' => $request->user()->id,
'business_id' => $request->user()->business_id,
'business_id' => $business->id,
'provider' => 'google',
]);
$params = http_build_query([
'client_id' => config('services.google.client_id'),
'redirect_uri' => route('seller.crm.calendar.callback'),
'redirect_uri' => route('seller.business.crm.calendar.callback', $business),
'response_type' => 'code',
'scope' => 'https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/calendar.events',
'access_type' => 'offline',
@@ -117,17 +116,17 @@ class CrmCalendarController extends Controller
/**
* Start OAuth flow for Outlook Calendar
*/
public function connectOutlook(Request $request)
public function connectOutlook(Request $request, Business $business)
{
$state = encrypt([
'user_id' => $request->user()->id,
'business_id' => $request->user()->business_id,
'business_id' => $business->id,
'provider' => 'outlook',
]);
$params = http_build_query([
'client_id' => config('services.microsoft.client_id'),
'redirect_uri' => route('seller.crm.calendar.callback'),
'redirect_uri' => route('seller.business.crm.calendar.callback', $business),
'response_type' => 'code',
'scope' => 'offline_access Calendars.ReadWrite',
'state' => $state,
@@ -139,17 +138,17 @@ class CrmCalendarController extends Controller
/**
* OAuth callback
*/
public function callback(Request $request)
public function callback(Request $request, Business $business)
{
if ($request->has('error')) {
return redirect()->route('seller.crm.calendar.connections')
return redirect()->route('seller.business.crm.calendar.connections', $business)
->withErrors(['error' => 'Authorization failed: '.$request->input('error_description')]);
}
try {
$state = decrypt($request->input('state'));
} catch (\Exception $e) {
return redirect()->route('seller.crm.calendar.connections')
return redirect()->route('seller.business.crm.calendar.connections', $business)
->withErrors(['error' => 'Invalid state parameter.']);
}
@@ -160,7 +159,7 @@ class CrmCalendarController extends Controller
$tokens = $this->calendarService->exchangeCodeForTokens($provider, $code);
if (! $tokens) {
return redirect()->route('seller.crm.calendar.connections')
return redirect()->route('seller.business.crm.calendar.connections', $business)
->withErrors(['error' => 'Failed to obtain access token.']);
}
@@ -188,17 +187,15 @@ class CrmCalendarController extends Controller
// Queue initial sync
SyncCalendarJob::dispatch($state['user_id'], $provider);
return redirect()->route('seller.crm.calendar.connections')
return redirect()->route('seller.business.crm.calendar.connections', $business)
->with('success', ucfirst($provider).' Calendar connected successfully.');
}
/**
* Disconnect a calendar
*/
public function disconnect(Request $request, CrmCalendarConnection $connection)
public function disconnect(Request $request, Business $business, CrmCalendarConnection $connection)
{
$business = $request->user()->business;
if ($connection->business_id !== $business->id) {
abort(404);
}
@@ -215,10 +212,8 @@ class CrmCalendarController extends Controller
/**
* Toggle sync for a connection
*/
public function toggleSync(Request $request, CrmCalendarConnection $connection)
public function toggleSync(Request $request, Business $business, CrmCalendarConnection $connection)
{
$business = $request->user()->business;
if ($connection->business_id !== $business->id) {
abort(404);
}
@@ -231,10 +226,8 @@ class CrmCalendarController extends Controller
/**
* Force sync a calendar
*/
public function sync(Request $request, CrmCalendarConnection $connection)
public function sync(Request $request, Business $business, CrmCalendarConnection $connection)
{
$business = $request->user()->business;
if ($connection->business_id !== $business->id) {
abort(404);
}
@@ -247,9 +240,8 @@ class CrmCalendarController extends Controller
/**
* API: Get events for date range (for calendar JS)
*/
public function events(Request $request)
public function events(Request $request, Business $business)
{
$business = $request->user()->business;
$user = $request->user();
$validated = $request->validate([

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Seller\Crm;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Crm\CrmDeal;
use App\Models\Crm\CrmRepMetric;
use App\Models\Crm\CrmSlaTimer;
@@ -20,9 +21,8 @@ class CrmDashboardController extends Controller
/**
* Main CRM dashboard
*/
public function index(Request $request)
public function index(Request $request, Business $business)
{
$business = $request->user()->business;
$user = $request->user();
// Cache dashboard data for 1 minute
@@ -38,10 +38,8 @@ class CrmDashboardController extends Controller
/**
* Sales performance dashboard
*/
public function sales(Request $request)
public function sales(Request $request, Business $business)
{
$business = $request->user()->business;
// Pipeline summary
$pipelineSummary = CrmDeal::forBusiness($business->id)
->open()
@@ -91,10 +89,8 @@ class CrmDashboardController extends Controller
/**
* Team performance dashboard
*/
public function team(Request $request)
public function team(Request $request, Business $business)
{
$business = $request->user()->business;
// SLA metrics
$slaMetrics = $this->slaService->getMetrics($business->id, 30);

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Seller\Crm;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Crm\CrmChannel;
use App\Models\Crm\CrmMessageTemplate;
use App\Models\Crm\CrmPipeline;
@@ -16,10 +17,8 @@ class CrmSettingsController extends Controller
/**
* Settings overview
*/
public function index(Request $request)
public function index(Request $request, Business $business)
{
$business = $request->user()->business;
$stats = [
'channels' => CrmChannel::where('business_id', $business->id)->count(),
'pipelines' => CrmPipeline::where('business_id', $business->id)->count(),
@@ -36,10 +35,8 @@ class CrmSettingsController extends Controller
/**
* List channels
*/
public function channels(Request $request)
public function channels(Request $request, Business $business)
{
$business = $request->user()->business;
$channels = CrmChannel::where('business_id', $business->id)
->orderBy('type')
->get()
@@ -51,7 +48,7 @@ class CrmSettingsController extends Controller
/**
* Create channel form
*/
public function createChannel(Request $request)
public function createChannel(Request $request, Business $business)
{
$types = CrmChannel::TYPES;
@@ -61,10 +58,8 @@ class CrmSettingsController extends Controller
/**
* Store channel
*/
public function storeChannel(Request $request)
public function storeChannel(Request $request, Business $business)
{
$business = $request->user()->business;
$validated = $request->validate([
'name' => 'required|string|max:255',
'type' => 'required|in:'.implode(',', CrmChannel::TYPES),
@@ -88,17 +83,15 @@ class CrmSettingsController extends Controller
'is_default' => $validated['is_default'] ?? false,
]);
return redirect()->route('seller.crm.settings.channels')
return redirect()->route('seller.business.crm.settings.channels', $business)
->with('success', 'Channel created successfully.');
}
/**
* Edit channel
*/
public function editChannel(Request $request, CrmChannel $channel)
public function editChannel(Request $request, Business $business, CrmChannel $channel)
{
$business = $request->user()->business;
if ($channel->business_id !== $business->id) {
abort(404);
}
@@ -111,10 +104,8 @@ class CrmSettingsController extends Controller
/**
* Update channel
*/
public function updateChannel(Request $request, CrmChannel $channel)
public function updateChannel(Request $request, Business $business, CrmChannel $channel)
{
$business = $request->user()->business;
if ($channel->business_id !== $business->id) {
abort(404);
}
@@ -136,17 +127,15 @@ class CrmSettingsController extends Controller
$channel->update($validated);
return redirect()->route('seller.crm.settings.channels')
return redirect()->route('seller.business.crm.settings.channels', $business)
->with('success', 'Channel updated.');
}
/**
* Delete channel
*/
public function destroyChannel(Request $request, CrmChannel $channel)
public function destroyChannel(Request $request, Business $business, CrmChannel $channel)
{
$business = $request->user()->business;
if ($channel->business_id !== $business->id) {
abort(404);
}
@@ -161,10 +150,8 @@ class CrmSettingsController extends Controller
/**
* List pipelines
*/
public function pipelines(Request $request)
public function pipelines(Request $request, Business $business)
{
$business = $request->user()->business;
$pipelines = CrmPipeline::where('business_id', $business->id)
->withCount('deals')
->orderBy('name')
@@ -176,7 +163,7 @@ class CrmSettingsController extends Controller
/**
* Create pipeline form
*/
public function createPipeline()
public function createPipeline(Request $request, Business $business)
{
return view('seller.crm.settings.pipelines.create');
}
@@ -184,10 +171,8 @@ class CrmSettingsController extends Controller
/**
* Store pipeline
*/
public function storePipeline(Request $request)
public function storePipeline(Request $request, Business $business)
{
$business = $request->user()->business;
$validated = $request->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string|max:1000',
@@ -213,17 +198,15 @@ class CrmSettingsController extends Controller
'is_default' => $validated['is_default'] ?? false,
]);
return redirect()->route('seller.crm.settings.pipelines')
return redirect()->route('seller.business.crm.settings.pipelines', $business)
->with('success', 'Pipeline created.');
}
/**
* Edit pipeline
*/
public function editPipeline(Request $request, CrmPipeline $pipeline)
public function editPipeline(Request $request, Business $business, CrmPipeline $pipeline)
{
$business = $request->user()->business;
if ($pipeline->business_id !== $business->id) {
abort(404);
}
@@ -234,10 +217,8 @@ class CrmSettingsController extends Controller
/**
* Update pipeline
*/
public function updatePipeline(Request $request, CrmPipeline $pipeline)
public function updatePipeline(Request $request, Business $business, CrmPipeline $pipeline)
{
$business = $request->user()->business;
if ($pipeline->business_id !== $business->id) {
abort(404);
}
@@ -262,17 +243,15 @@ class CrmSettingsController extends Controller
$pipeline->update($validated);
return redirect()->route('seller.crm.settings.pipelines')
return redirect()->route('seller.business.crm.settings.pipelines', $business)
->with('success', 'Pipeline updated.');
}
/**
* Delete pipeline
*/
public function destroyPipeline(Request $request, CrmPipeline $pipeline)
public function destroyPipeline(Request $request, Business $business, CrmPipeline $pipeline)
{
$business = $request->user()->business;
if ($pipeline->business_id !== $business->id) {
abort(404);
}
@@ -291,10 +270,8 @@ class CrmSettingsController extends Controller
/**
* List SLA policies
*/
public function slaPolicies(Request $request)
public function slaPolicies(Request $request, Business $business)
{
$business = $request->user()->business;
$policies = CrmSlaPolicy::where('business_id', $business->id)
->orderBy('priority')
->get();
@@ -305,7 +282,7 @@ class CrmSettingsController extends Controller
/**
* Create SLA policy form
*/
public function createSlaPolicy()
public function createSlaPolicy(Request $request, Business $business)
{
return view('seller.crm.settings.sla.create');
}
@@ -313,10 +290,8 @@ class CrmSettingsController extends Controller
/**
* Store SLA policy
*/
public function storeSlaPolicy(Request $request)
public function storeSlaPolicy(Request $request, Business $business)
{
$business = $request->user()->business;
$validated = $request->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string|max:1000',
@@ -342,17 +317,15 @@ class CrmSettingsController extends Controller
'is_active' => $validated['is_active'] ?? true,
]);
return redirect()->route('seller.crm.settings.sla')
return redirect()->route('seller.business.crm.settings.sla', $business)
->with('success', 'SLA policy created.');
}
/**
* Edit SLA policy
*/
public function editSlaPolicy(Request $request, CrmSlaPolicy $policy)
public function editSlaPolicy(Request $request, Business $business, CrmSlaPolicy $policy)
{
$business = $request->user()->business;
if ($policy->business_id !== $business->id) {
abort(404);
}
@@ -363,10 +336,8 @@ class CrmSettingsController extends Controller
/**
* Update SLA policy
*/
public function updateSlaPolicy(Request $request, CrmSlaPolicy $policy)
public function updateSlaPolicy(Request $request, Business $business, CrmSlaPolicy $policy)
{
$business = $request->user()->business;
if ($policy->business_id !== $business->id) {
abort(404);
}
@@ -385,17 +356,15 @@ class CrmSettingsController extends Controller
$policy->update($validated);
return redirect()->route('seller.crm.settings.sla')
return redirect()->route('seller.business.crm.settings.sla', $business)
->with('success', 'SLA policy updated.');
}
/**
* Delete SLA policy
*/
public function destroySlaPolicy(Request $request, CrmSlaPolicy $policy)
public function destroySlaPolicy(Request $request, Business $business, CrmSlaPolicy $policy)
{
$business = $request->user()->business;
if ($policy->business_id !== $business->id) {
abort(404);
}
@@ -410,10 +379,8 @@ class CrmSettingsController extends Controller
/**
* List tags
*/
public function tags(Request $request)
public function tags(Request $request, Business $business)
{
$business = $request->user()->business;
$tags = CrmTag::where('business_id', $business->id)
->withCount('taggables')
->orderBy('name')
@@ -425,10 +392,8 @@ class CrmSettingsController extends Controller
/**
* Store tag
*/
public function storeTag(Request $request)
public function storeTag(Request $request, Business $business)
{
$business = $request->user()->business;
$validated = $request->validate([
'name' => 'required|string|max:50',
'color' => 'required|string|max:20',
@@ -448,10 +413,8 @@ class CrmSettingsController extends Controller
/**
* Update tag
*/
public function updateTag(Request $request, CrmTag $tag)
public function updateTag(Request $request, Business $business, CrmTag $tag)
{
$business = $request->user()->business;
if ($tag->business_id !== $business->id) {
abort(404);
}
@@ -470,10 +433,8 @@ class CrmSettingsController extends Controller
/**
* Delete tag
*/
public function destroyTag(Request $request, CrmTag $tag)
public function destroyTag(Request $request, Business $business, CrmTag $tag)
{
$business = $request->user()->business;
if ($tag->business_id !== $business->id) {
abort(404);
}
@@ -488,10 +449,8 @@ class CrmSettingsController extends Controller
/**
* List templates
*/
public function templates(Request $request)
public function templates(Request $request, Business $business)
{
$business = $request->user()->business;
$templates = CrmMessageTemplate::where('business_id', $business->id)
->orderBy('category')
->orderBy('name')
@@ -504,7 +463,7 @@ class CrmSettingsController extends Controller
/**
* Create template form
*/
public function createTemplate()
public function createTemplate(Request $request, Business $business)
{
$categories = CrmMessageTemplate::CATEGORIES;
$channels = CrmChannel::TYPES;
@@ -515,10 +474,8 @@ class CrmSettingsController extends Controller
/**
* Store template
*/
public function storeTemplate(Request $request)
public function storeTemplate(Request $request, Business $business)
{
$business = $request->user()->business;
$validated = $request->validate([
'name' => 'required|string|max:255',
'category' => 'required|string|in:'.implode(',', CrmMessageTemplate::CATEGORIES),
@@ -540,17 +497,15 @@ class CrmSettingsController extends Controller
'is_active' => true,
]);
return redirect()->route('seller.crm.settings.templates')
return redirect()->route('seller.business.crm.settings.templates', $business)
->with('success', 'Template created.');
}
/**
* Edit template
*/
public function editTemplate(Request $request, CrmMessageTemplate $template)
public function editTemplate(Request $request, Business $business, CrmMessageTemplate $template)
{
$business = $request->user()->business;
if ($template->business_id !== $business->id) {
abort(404);
}
@@ -564,10 +519,8 @@ class CrmSettingsController extends Controller
/**
* Update template
*/
public function updateTemplate(Request $request, CrmMessageTemplate $template)
public function updateTemplate(Request $request, Business $business, CrmMessageTemplate $template)
{
$business = $request->user()->business;
if ($template->business_id !== $business->id) {
abort(404);
}
@@ -583,17 +536,15 @@ class CrmSettingsController extends Controller
$template->update($validated);
return redirect()->route('seller.crm.settings.templates')
return redirect()->route('seller.business.crm.settings.templates', $business)
->with('success', 'Template updated.');
}
/**
* Delete template
*/
public function destroyTemplate(Request $request, CrmMessageTemplate $template)
public function destroyTemplate(Request $request, Business $business, CrmMessageTemplate $template)
{
$business = $request->user()->business;
if ($template->business_id !== $business->id) {
abort(404);
}
@@ -608,10 +559,8 @@ class CrmSettingsController extends Controller
/**
* List team roles
*/
public function teamRoles(Request $request)
public function teamRoles(Request $request, Business $business)
{
$business = $request->user()->business;
$roles = CrmTeamRole::where('business_id', $business->id)
->withCount('users')
->orderBy('name')
@@ -623,10 +572,8 @@ class CrmSettingsController extends Controller
/**
* Store team role
*/
public function storeTeamRole(Request $request)
public function storeTeamRole(Request $request, Business $business)
{
$business = $request->user()->business;
$validated = $request->validate([
'name' => 'required|string|max:100',
'permissions' => 'required|array',
@@ -644,10 +591,8 @@ class CrmSettingsController extends Controller
/**
* Update team role
*/
public function updateTeamRole(Request $request, CrmTeamRole $role)
public function updateTeamRole(Request $request, Business $business, CrmTeamRole $role)
{
$business = $request->user()->business;
if ($role->business_id !== $business->id) {
abort(404);
}
@@ -665,10 +610,8 @@ class CrmSettingsController extends Controller
/**
* Delete team role
*/
public function destroyTeamRole(Request $request, CrmTeamRole $role)
public function destroyTeamRole(Request $request, Business $business, CrmTeamRole $role)
{
$business = $request->user()->business;
if ($role->business_id !== $business->id) {
abort(404);
}

View File

@@ -22,10 +22,8 @@ class DealController extends Controller
/**
* Display pipeline board view
*/
public function index(Request $request)
public function index(Request $request, Business $business)
{
$business = $request->user()->business;
// Get active pipeline
$pipeline = CrmPipeline::forBusiness($business->id)
->where('id', $request->input('pipeline_id'))
@@ -79,10 +77,8 @@ class DealController extends Controller
/**
* Show create deal form
*/
public function create(Request $request)
public function create(Request $request, Business $business)
{
$business = $request->user()->business;
$pipelines = CrmPipeline::forBusiness($business->id)->active()->get();
$contacts = Contact::where('business_id', $business->id)->get();
$accounts = Business::whereHas('ordersAsCustomer', function ($q) use ($business) {
@@ -97,10 +93,8 @@ class DealController extends Controller
/**
* Store new deal
*/
public function store(Request $request)
public function store(Request $request, Business $business)
{
$business = $request->user()->business;
$validated = $request->validate([
'name' => 'required|string|max:255',
'pipeline_id' => 'required|exists:crm_pipelines,id',
@@ -161,17 +155,15 @@ class DealController extends Controller
'status' => CrmDeal::STATUS_OPEN,
]);
return redirect()->route('seller.crm.deals.show', $deal)
return redirect()->route('seller.business.crm.deals.show', [$business, $deal])
->with('success', 'Deal created successfully.');
}
/**
* Show deal details
*/
public function show(Request $request, CrmDeal $deal)
public function show(Request $request, Business $business, CrmDeal $deal)
{
$business = $request->user()->business;
if ($deal->business_id !== $business->id) {
abort(404);
}
@@ -205,10 +197,8 @@ class DealController extends Controller
/**
* Update deal stage (drag & drop)
*/
public function updateStage(Request $request, CrmDeal $deal)
public function updateStage(Request $request, Business $business, CrmDeal $deal)
{
$business = $request->user()->business;
if ($deal->business_id !== $business->id) {
return response()->json(['error' => 'Unauthorized'], 403);
}
@@ -232,10 +222,8 @@ class DealController extends Controller
/**
* Mark deal as won
*/
public function markWon(Request $request, CrmDeal $deal)
public function markWon(Request $request, Business $business, CrmDeal $deal)
{
$business = $request->user()->business;
if ($deal->business_id !== $business->id) {
abort(404);
}
@@ -253,10 +241,8 @@ class DealController extends Controller
/**
* Mark deal as lost
*/
public function markLost(Request $request, CrmDeal $deal)
public function markLost(Request $request, Business $business, CrmDeal $deal)
{
$business = $request->user()->business;
if ($deal->business_id !== $business->id) {
abort(404);
}
@@ -280,10 +266,8 @@ class DealController extends Controller
/**
* Reopen a closed deal
*/
public function reopen(Request $request, CrmDeal $deal)
public function reopen(Request $request, Business $business, CrmDeal $deal)
{
$business = $request->user()->business;
if ($deal->business_id !== $business->id) {
abort(404);
}
@@ -296,10 +280,8 @@ class DealController extends Controller
/**
* Update deal details
*/
public function update(Request $request, CrmDeal $deal)
public function update(Request $request, Business $business, CrmDeal $deal)
{
$business = $request->user()->business;
if ($deal->business_id !== $business->id) {
abort(404);
}
@@ -338,17 +320,15 @@ class DealController extends Controller
/**
* Delete deal
*/
public function destroy(Request $request, CrmDeal $deal)
public function destroy(Request $request, Business $business, CrmDeal $deal)
{
$business = $request->user()->business;
if ($deal->business_id !== $business->id) {
abort(404);
}
$deal->delete();
return redirect()->route('seller.crm.deals.index')
return redirect()->route('seller.business.crm.deals.index', $business)
->with('success', 'Deal deleted.');
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Seller\Crm;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Crm\CrmInvoice;
use App\Models\Crm\CrmInvoiceItem;
use App\Models\Crm\CrmInvoicePayment;
@@ -14,10 +15,8 @@ class InvoiceController extends Controller
/**
* List invoices
*/
public function index(Request $request)
public function index(Request $request, Business $business)
{
$business = $request->user()->business;
$query = CrmInvoice::forBusiness($business->id)
->with(['contact', 'account', 'creator'])
->withCount('items');
@@ -56,10 +55,8 @@ class InvoiceController extends Controller
/**
* Show invoice details
*/
public function show(Request $request, CrmInvoice $invoice)
public function show(Request $request, Business $business, CrmInvoice $invoice)
{
$business = $request->user()->business;
if ($invoice->business_id !== $business->id) {
abort(404);
}
@@ -72,10 +69,8 @@ class InvoiceController extends Controller
/**
* Create invoice form
*/
public function create(Request $request)
public function create(Request $request, Business $business)
{
$business = $request->user()->business;
$contacts = \App\Models\Contact::where('business_id', $business->id)->get();
$quotes = CrmQuote::forBusiness($business->id)
->where('status', CrmQuote::STATUS_ACCEPTED)
@@ -88,10 +83,8 @@ class InvoiceController extends Controller
/**
* Store new invoice
*/
public function store(Request $request)
public function store(Request $request, Business $business)
{
$business = $request->user()->business;
$validated = $request->validate([
'title' => 'required|string|max:255',
'contact_id' => 'required|exists:contacts,id',
@@ -151,17 +144,15 @@ class InvoiceController extends Controller
$invoice->calculateTotals();
return redirect()->route('seller.crm.invoices.show', $invoice)
return redirect()->route('seller.business.crm.invoices.show', [$business, $invoice])
->with('success', 'Invoice created successfully.');
}
/**
* Send invoice to contact
*/
public function send(Request $request, CrmInvoice $invoice)
public function send(Request $request, Business $business, CrmInvoice $invoice)
{
$business = $request->user()->business;
if ($invoice->business_id !== $business->id) {
abort(404);
}
@@ -180,10 +171,8 @@ class InvoiceController extends Controller
/**
* Record a payment
*/
public function recordPayment(Request $request, CrmInvoice $invoice)
public function recordPayment(Request $request, Business $business, CrmInvoice $invoice)
{
$business = $request->user()->business;
if ($invoice->business_id !== $business->id) {
abort(404);
}
@@ -223,10 +212,8 @@ class InvoiceController extends Controller
/**
* Mark invoice as void
*/
public function void(Request $request, CrmInvoice $invoice)
public function void(Request $request, Business $business, CrmInvoice $invoice)
{
$business = $request->user()->business;
if ($invoice->business_id !== $business->id) {
abort(404);
}
@@ -247,10 +234,8 @@ class InvoiceController extends Controller
/**
* Download invoice PDF
*/
public function download(Request $request, CrmInvoice $invoice)
public function download(Request $request, Business $business, CrmInvoice $invoice)
{
$business = $request->user()->business;
if ($invoice->business_id !== $business->id) {
abort(404);
}
@@ -262,10 +247,8 @@ class InvoiceController extends Controller
/**
* Delete invoice
*/
public function destroy(Request $request, CrmInvoice $invoice)
public function destroy(Request $request, Business $business, CrmInvoice $invoice)
{
$business = $request->user()->business;
if ($invoice->business_id !== $business->id) {
abort(404);
}
@@ -276,7 +259,7 @@ class InvoiceController extends Controller
$invoice->delete();
return redirect()->route('seller.crm.invoices.index')
return redirect()->route('seller.business.crm.invoices.index', $business)
->with('success', 'Invoice deleted.');
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Seller\Crm;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Crm\CrmMeetingBooking;
use App\Models\Crm\CrmMeetingLink;
use App\Services\Crm\CrmCalendarService;
@@ -17,9 +18,8 @@ class MeetingLinkController extends Controller
/**
* List meeting links
*/
public function index(Request $request)
public function index(Request $request, Business $business)
{
$business = $request->user()->business;
$user = $request->user();
$meetingLinks = CrmMeetingLink::where('business_id', $business->id)
@@ -34,7 +34,7 @@ class MeetingLinkController extends Controller
/**
* Create meeting link form
*/
public function create()
public function create(Request $request, Business $business)
{
return view('seller.crm.meetings.links.create');
}
@@ -42,9 +42,8 @@ class MeetingLinkController extends Controller
/**
* Store new meeting link
*/
public function store(Request $request)
public function store(Request $request, Business $business)
{
$business = $request->user()->business;
$user = $request->user();
$validated = $request->validate([
@@ -82,17 +81,15 @@ class MeetingLinkController extends Controller
'is_active' => $validated['is_active'] ?? true,
]);
return redirect()->route('seller.crm.meetings.links.show', $meetingLink)
return redirect()->route('seller.business.crm.meetings.links.show', [$business, $meetingLink])
->with('success', 'Meeting link created. Share the booking URL with contacts.');
}
/**
* Show meeting link details
*/
public function show(Request $request, CrmMeetingLink $meetingLink)
public function show(Request $request, Business $business, CrmMeetingLink $meetingLink)
{
$business = $request->user()->business;
if ($meetingLink->business_id !== $business->id) {
abort(404);
}
@@ -105,10 +102,8 @@ class MeetingLinkController extends Controller
/**
* Edit meeting link
*/
public function edit(Request $request, CrmMeetingLink $meetingLink)
public function edit(Request $request, Business $business, CrmMeetingLink $meetingLink)
{
$business = $request->user()->business;
if ($meetingLink->business_id !== $business->id) {
abort(404);
}
@@ -119,10 +114,8 @@ class MeetingLinkController extends Controller
/**
* Update meeting link
*/
public function update(Request $request, CrmMeetingLink $meetingLink)
public function update(Request $request, Business $business, CrmMeetingLink $meetingLink)
{
$business = $request->user()->business;
if ($meetingLink->business_id !== $business->id) {
abort(404);
}
@@ -143,17 +136,15 @@ class MeetingLinkController extends Controller
$meetingLink->update($validated);
return redirect()->route('seller.crm.meetings.links.show', $meetingLink)
return redirect()->route('seller.business.crm.meetings.links.show', [$business, $meetingLink])
->with('success', 'Meeting link updated.');
}
/**
* Toggle active status
*/
public function toggle(Request $request, CrmMeetingLink $meetingLink)
public function toggle(Request $request, Business $business, CrmMeetingLink $meetingLink)
{
$business = $request->user()->business;
if ($meetingLink->business_id !== $business->id) {
abort(404);
}
@@ -166,17 +157,15 @@ class MeetingLinkController extends Controller
/**
* Delete meeting link
*/
public function destroy(Request $request, CrmMeetingLink $meetingLink)
public function destroy(Request $request, Business $business, CrmMeetingLink $meetingLink)
{
$business = $request->user()->business;
if ($meetingLink->business_id !== $business->id) {
abort(404);
}
$meetingLink->delete();
return redirect()->route('seller.crm.meetings.links.index')
return redirect()->route('seller.business.crm.meetings.links.index', $business)
->with('success', 'Meeting link deleted.');
}
@@ -250,9 +239,8 @@ class MeetingLinkController extends Controller
/**
* List upcoming bookings
*/
public function bookings(Request $request)
public function bookings(Request $request, Business $business)
{
$business = $request->user()->business;
$user = $request->user();
$bookings = CrmMeetingBooking::whereHas('meetingLink', function ($q) use ($business, $user) {
@@ -270,10 +258,8 @@ class MeetingLinkController extends Controller
/**
* Cancel a booking
*/
public function cancelBooking(Request $request, CrmMeetingBooking $booking)
public function cancelBooking(Request $request, Business $business, CrmMeetingBooking $booking)
{
$business = $request->user()->business;
if ($booking->meetingLink->business_id !== $business->id) {
abort(404);
}

View File

@@ -16,10 +16,8 @@ class QuoteController extends Controller
/**
* List quotes
*/
public function index(Request $request)
public function index(Request $request, Business $business)
{
$business = $request->user()->business;
$query = CrmQuote::forBusiness($business->id)
->with(['contact', 'account', 'deal', 'creator'])
->withCount('items');
@@ -44,10 +42,8 @@ class QuoteController extends Controller
/**
* Show create quote form
*/
public function create(Request $request)
public function create(Request $request, Business $business)
{
$business = $request->user()->business;
$contacts = Contact::where('business_id', $business->id)->get();
$accounts = Business::whereHas('ordersAsCustomer', function ($q) use ($business) {
$q->whereHas('items.product.brand', fn ($b) => $b->where('business_id', $business->id));
@@ -68,10 +64,8 @@ class QuoteController extends Controller
/**
* Store new quote
*/
public function store(Request $request)
public function store(Request $request, Business $business)
{
$business = $request->user()->business;
$validated = $request->validate([
'title' => 'required|string|max:255',
'contact_id' => 'required|exists:contacts,id',
@@ -141,17 +135,15 @@ class QuoteController extends Controller
$quote->calculateTotals();
return redirect()->route('seller.crm.quotes.show', $quote)
return redirect()->route('seller.business.crm.quotes.show', [$business, $quote])
->with('success', 'Quote created successfully.');
}
/**
* Show quote details
*/
public function show(Request $request, CrmQuote $quote)
public function show(Request $request, Business $business, CrmQuote $quote)
{
$business = $request->user()->business;
if ($quote->business_id !== $business->id) {
abort(404);
}
@@ -164,10 +156,8 @@ class QuoteController extends Controller
/**
* Edit quote
*/
public function edit(Request $request, CrmQuote $quote)
public function edit(Request $request, Business $business, CrmQuote $quote)
{
$business = $request->user()->business;
if ($quote->business_id !== $business->id) {
abort(404);
}
@@ -191,10 +181,8 @@ class QuoteController extends Controller
/**
* Update quote
*/
public function update(Request $request, CrmQuote $quote)
public function update(Request $request, Business $business, CrmQuote $quote)
{
$business = $request->user()->business;
if ($quote->business_id !== $business->id) {
abort(404);
}
@@ -246,17 +234,15 @@ class QuoteController extends Controller
$quote->calculateTotals();
return redirect()->route('seller.crm.quotes.show', $quote)
return redirect()->route('seller.business.crm.quotes.show', [$business, $quote])
->with('success', 'Quote updated successfully.');
}
/**
* Send quote to contact
*/
public function send(Request $request, CrmQuote $quote)
public function send(Request $request, Business $business, CrmQuote $quote)
{
$business = $request->user()->business;
if ($quote->business_id !== $business->id) {
abort(404);
}
@@ -275,10 +261,8 @@ class QuoteController extends Controller
/**
* Convert quote to invoice
*/
public function convertToInvoice(Request $request, CrmQuote $quote)
public function convertToInvoice(Request $request, Business $business, CrmQuote $quote)
{
$business = $request->user()->business;
if ($quote->business_id !== $business->id) {
abort(404);
}
@@ -293,17 +277,15 @@ class QuoteController extends Controller
$invoice = $quote->convertToInvoice();
return redirect()->route('seller.crm.invoices.show', $invoice)
return redirect()->route('seller.business.crm.invoices.show', [$business, $invoice])
->with('success', 'Invoice created from quote.');
}
/**
* Download quote PDF
*/
public function download(Request $request, CrmQuote $quote)
public function download(Request $request, Business $business, CrmQuote $quote)
{
$business = $request->user()->business;
if ($quote->business_id !== $business->id) {
abort(404);
}
@@ -315,17 +297,15 @@ class QuoteController extends Controller
/**
* Delete quote
*/
public function destroy(Request $request, CrmQuote $quote)
public function destroy(Request $request, Business $business, CrmQuote $quote)
{
$business = $request->user()->business;
if ($quote->business_id !== $business->id) {
abort(404);
}
$quote->delete();
return redirect()->route('seller.crm.quotes.index')
return redirect()->route('seller.business.crm.quotes.index', $business)
->with('success', 'Quote deleted.');
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Seller\Crm;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Crm\CrmActiveView;
use App\Models\Crm\CrmChannel;
use App\Models\Crm\CrmInternalNote;
@@ -24,10 +25,8 @@ class ThreadController extends Controller
/**
* Display unified inbox
*/
public function index(Request $request)
public function index(Request $request, Business $business)
{
$business = $request->user()->business;
$query = CrmThread::forBusiness($business->id)
->with(['contact', 'assignee', 'messages' => fn ($q) => $q->latest()->limit(1)])
->withCount('messages');
@@ -77,10 +76,8 @@ class ThreadController extends Controller
/**
* Show a single thread
*/
public function show(Request $request, CrmThread $thread)
public function show(Request $request, Business $business, CrmThread $thread)
{
$business = $request->user()->business;
// SECURITY: Verify business ownership
if ($thread->business_id !== $business->id) {
abort(404);
@@ -128,10 +125,8 @@ class ThreadController extends Controller
/**
* Send a reply in thread
*/
public function reply(Request $request, CrmThread $thread)
public function reply(Request $request, Business $business, CrmThread $thread)
{
$business = $request->user()->business;
if ($thread->business_id !== $business->id) {
abort(404);
}
@@ -177,10 +172,8 @@ class ThreadController extends Controller
/**
* Assign thread to user
*/
public function assign(Request $request, CrmThread $thread)
public function assign(Request $request, Business $business, CrmThread $thread)
{
$business = $request->user()->business;
if ($thread->business_id !== $business->id) {
abort(404);
}
@@ -206,10 +199,8 @@ class ThreadController extends Controller
/**
* Close thread
*/
public function close(Request $request, CrmThread $thread)
public function close(Request $request, Business $business, CrmThread $thread)
{
$business = $request->user()->business;
if ($thread->business_id !== $business->id) {
abort(404);
}
@@ -222,10 +213,8 @@ class ThreadController extends Controller
/**
* Reopen thread
*/
public function reopen(Request $request, CrmThread $thread)
public function reopen(Request $request, Business $business, CrmThread $thread)
{
$business = $request->user()->business;
if ($thread->business_id !== $business->id) {
abort(404);
}
@@ -241,10 +230,8 @@ class ThreadController extends Controller
/**
* Snooze thread
*/
public function snooze(Request $request, CrmThread $thread)
public function snooze(Request $request, Business $business, CrmThread $thread)
{
$business = $request->user()->business;
if ($thread->business_id !== $business->id) {
abort(404);
}
@@ -264,10 +251,8 @@ class ThreadController extends Controller
/**
* Add internal note
*/
public function addNote(Request $request, CrmThread $thread)
public function addNote(Request $request, Business $business, CrmThread $thread)
{
$business = $request->user()->business;
if ($thread->business_id !== $business->id) {
abort(404);
}
@@ -290,10 +275,8 @@ class ThreadController extends Controller
/**
* Generate AI reply draft
*/
public function generateAiReply(Request $request, CrmThread $thread)
public function generateAiReply(Request $request, Business $business, CrmThread $thread)
{
$business = $request->user()->business;
if ($thread->business_id !== $business->id) {
abort(404);
}
@@ -313,10 +296,8 @@ class ThreadController extends Controller
/**
* Heartbeat for active viewing
*/
public function heartbeat(Request $request, CrmThread $thread)
public function heartbeat(Request $request, Business $business, CrmThread $thread)
{
$business = $request->user()->business;
if ($thread->business_id !== $business->id) {
return response()->json(['error' => 'Unauthorized'], 403);
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Order;
use App\Services\FulfillmentService;
use App\Services\InvoiceService;
@@ -23,10 +24,8 @@ class DeliveryController extends Controller
/**
* Show delivery confirmation form
*/
public function show(Request $request, Order $order): View
public function show(Request $request, Business $business, Order $order): View
{
$business = $request->user()->businesses()->first();
// Ensure order belongs to seller's business
if ($order->seller_business_id !== $business->id) {
abort(403, 'Unauthorized access to order');
@@ -45,10 +44,8 @@ class DeliveryController extends Controller
/**
* Confirm delivery and record acceptance/rejection
*/
public function confirm(Request $request, Order $order): RedirectResponse
public function confirm(Request $request, Business $business, Order $order): RedirectResponse
{
$business = $request->user()->businesses()->first();
// Business isolation: Ensure order belongs to seller's business
if ($order->seller_business_id !== $business->id) {
abort(403);

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\DeliveryWindow;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -15,10 +16,8 @@ class DeliveryWindowController extends Controller
/**
* List delivery windows for seller's business
*/
public function index(Request $request): View
public function index(Request $request, Business $business): View
{
$business = $request->user()->businesses()->first();
$windows = DeliveryWindow::where('business_id', $business->id)
->orderBy('day_of_week')
->orderBy('start_time')
@@ -30,7 +29,7 @@ class DeliveryWindowController extends Controller
/**
* Store a new delivery window
*/
public function store(Request $request): RedirectResponse
public function store(Request $request, Business $business): RedirectResponse
{
$validated = $request->validate([
'day_of_week' => 'required|integer|between:0,6',
@@ -39,8 +38,6 @@ class DeliveryWindowController extends Controller
'is_active' => 'boolean',
]);
$business = $request->user()->businesses()->first();
DeliveryWindow::create([
'business_id' => $business->id,
'day_of_week' => $validated['day_of_week'],
@@ -50,17 +47,15 @@ class DeliveryWindowController extends Controller
]);
return redirect()
->route('seller.delivery-windows.index')
->route('seller.business.settings.delivery-windows.index', $business)
->with('success', 'Delivery window created successfully');
}
/**
* Update delivery window
*/
public function update(Request $request, DeliveryWindow $deliveryWindow): RedirectResponse
public function update(Request $request, Business $business, DeliveryWindow $deliveryWindow): RedirectResponse
{
$business = $request->user()->businesses()->first();
// Ensure window belongs to seller's business
if ($deliveryWindow->business_id !== $business->id) {
abort(403, 'Unauthorized access to delivery window');
@@ -81,17 +76,15 @@ class DeliveryWindowController extends Controller
]);
return redirect()
->route('seller.delivery-windows.index')
->route('seller.business.settings.delivery-windows.index', $business)
->with('success', 'Delivery window updated successfully');
}
/**
* Delete delivery window
*/
public function destroy(Request $request, DeliveryWindow $deliveryWindow): RedirectResponse
public function destroy(Request $request, Business $business, DeliveryWindow $deliveryWindow): RedirectResponse
{
$business = $request->user()->businesses()->first();
if ($deliveryWindow->business_id !== $business->id) {
abort(403);
}
@@ -99,7 +92,7 @@ class DeliveryWindowController extends Controller
$deliveryWindow->delete();
return redirect()
->route('seller.delivery-windows.index')
->route('seller.business.settings.delivery-windows.index', $business)
->with('success', 'Delivery window deleted successfully');
}
}

View File

@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Seller\Marketing;
use App\Http\Controllers\Controller;
use App\Models\Brand;
use App\Models\Business;
use App\Models\Marketing\Campaign;
use App\Models\Marketing\MarketingChannel;
use App\Models\Marketing\MarketingTemplate;
@@ -17,10 +18,8 @@ use Illuminate\Support\Facades\Log;
class CampaignController extends Controller
{
public function index(Request $request)
public function index(Request $request, Business $business)
{
$business = currentBusiness();
$campaigns = Campaign::forBusiness($business->id)
->with(['brand', 'creator'])
->when($request->status, fn ($q, $status) => $q->where('status', $status))
@@ -29,15 +28,14 @@ class CampaignController extends Controller
->latest()
->paginate(20);
$brands = $business->brands()->orderBy('name')->get();
$brands = Brand::where('business_id', $business->id)->orderBy('name')->get();
return view('seller.marketing.campaigns.index', compact('business', 'campaigns', 'brands'));
}
public function create(Request $request)
public function create(Request $request, Business $business)
{
$business = currentBusiness();
$brands = $business->brands()->orderBy('name')->get();
$brands = Brand::where('business_id', $business->id)->orderBy('name')->get();
$channels = MarketingChannel::forBusiness($business->id)->active()->get();
$templates = MarketingTemplate::forBusiness($business->id)->active()->get();
@@ -55,10 +53,8 @@ class CampaignController extends Controller
));
}
public function store(Request $request)
public function store(Request $request, Business $business)
{
$business = currentBusiness();
$validated = $request->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string',
@@ -71,6 +67,13 @@ class CampaignController extends Controller
'template_ids' => 'nullable|array',
]);
// SECURITY: Verify brand belongs to business if provided
if (! empty($validated['brand_id'])) {
Brand::where('id', $validated['brand_id'])
->where('business_id', $business->id)
->firstOrFail();
}
// Estimate usage cost
$recipientCount = $this->estimateRecipientCount($validated['target_segment'] ?? []);
$usageCostEstimate = UsageTracker::estimateCampaignCost(
@@ -102,13 +105,12 @@ class CampaignController extends Controller
}
return redirect()
->route('seller.business.marketing.campaigns.show', [$business->slug, $campaign])
->route('seller.business.marketing.campaigns.show', [$business, $campaign])
->with('success', 'Campaign created successfully.');
}
public function show(Request $request, $businessSlug, Campaign $campaign)
public function show(Request $request, Business $business, Campaign $campaign)
{
$business = currentBusiness();
$this->authorizeCampaign($campaign, $business);
$campaign->load(['brand', 'templates', 'creator']);
@@ -116,18 +118,17 @@ class CampaignController extends Controller
return view('seller.marketing.campaigns.show', compact('business', 'campaign'));
}
public function edit(Request $request, $businessSlug, Campaign $campaign)
public function edit(Request $request, Business $business, Campaign $campaign)
{
$business = currentBusiness();
$this->authorizeCampaign($campaign, $business);
if (! $campaign->canBeEdited()) {
return redirect()
->route('seller.business.marketing.campaigns.show', [$business->slug, $campaign])
->route('seller.business.marketing.campaigns.show', [$business, $campaign])
->with('error', 'This campaign cannot be edited in its current state.');
}
$brands = $business->brands()->orderBy('name')->get();
$brands = Brand::where('business_id', $business->id)->orderBy('name')->get();
$channels = MarketingChannel::forBusiness($business->id)->active()->get();
$templates = MarketingTemplate::forBusiness($business->id)->active()->get();
$campaign->load('templates');
@@ -141,14 +142,13 @@ class CampaignController extends Controller
));
}
public function update(Request $request, $businessSlug, Campaign $campaign)
public function update(Request $request, Business $business, Campaign $campaign)
{
$business = currentBusiness();
$this->authorizeCampaign($campaign, $business);
if (! $campaign->canBeEdited()) {
return redirect()
->route('seller.business.marketing.campaigns.show', [$business->slug, $campaign])
->route('seller.business.marketing.campaigns.show', [$business, $campaign])
->with('error', 'This campaign cannot be edited in its current state.');
}
@@ -164,6 +164,13 @@ class CampaignController extends Controller
'template_ids' => 'nullable|array',
]);
// SECURITY: Verify brand belongs to business if provided
if (! empty($validated['brand_id'])) {
Brand::where('id', $validated['brand_id'])
->where('business_id', $business->id)
->firstOrFail();
}
// Re-estimate usage cost
$recipientCount = $this->estimateRecipientCount($validated['target_segment'] ?? []);
$usageCostEstimate = UsageTracker::estimateCampaignCost(
@@ -193,25 +200,23 @@ class CampaignController extends Controller
}
return redirect()
->route('seller.business.marketing.campaigns.show', [$business->slug, $campaign])
->route('seller.business.marketing.campaigns.show', [$business, $campaign])
->with('success', 'Campaign updated successfully.');
}
public function destroy(Request $request, $businessSlug, Campaign $campaign)
public function destroy(Request $request, Business $business, Campaign $campaign)
{
$business = currentBusiness();
$this->authorizeCampaign($campaign, $business);
$campaign->delete();
return redirect()
->route('seller.business.marketing.campaigns.index', $business->slug)
->route('seller.business.marketing.campaigns.index', $business)
->with('success', 'Campaign deleted successfully.');
}
public function send(Request $request, $businessSlug, Campaign $campaign)
public function send(Request $request, Business $business, Campaign $campaign)
{
$business = currentBusiness();
$this->authorizeCampaign($campaign, $business);
if (! $campaign->canBeActivated()) {
@@ -234,9 +239,8 @@ class CampaignController extends Controller
/**
* Preview campaign without sending.
*/
public function preview(Request $request, $businessSlug, Campaign $campaign)
public function preview(Request $request, Business $business, Campaign $campaign)
{
$business = currentBusiness();
$this->authorizeCampaign($campaign, $business);
$previewData = $campaign->preview();
@@ -247,9 +251,8 @@ class CampaignController extends Controller
]);
}
public function pause(Request $request, $businessSlug, Campaign $campaign)
public function pause(Request $request, Business $business, Campaign $campaign)
{
$business = currentBusiness();
$this->authorizeCampaign($campaign, $business);
if (! $campaign->isActive()) {
@@ -261,9 +264,8 @@ class CampaignController extends Controller
return back()->with('success', 'Campaign paused.');
}
public function resume(Request $request, $businessSlug, Campaign $campaign)
public function resume(Request $request, Business $business, Campaign $campaign)
{
$business = currentBusiness();
$this->authorizeCampaign($campaign, $business);
if ($campaign->status !== Campaign::STATUS_PAUSED) {
@@ -275,9 +277,8 @@ class CampaignController extends Controller
return back()->with('success', 'Campaign resumed.');
}
public function cancel(Request $request, $businessSlug, Campaign $campaign)
public function cancel(Request $request, Business $business, Campaign $campaign)
{
$business = currentBusiness();
$this->authorizeCampaign($campaign, $business);
$campaign->cancel();
@@ -285,9 +286,8 @@ class CampaignController extends Controller
return back()->with('success', 'Campaign cancelled.');
}
public function duplicate(Request $request, $businessSlug, Campaign $campaign)
public function duplicate(Request $request, Business $business, Campaign $campaign)
{
$business = currentBusiness();
$this->authorizeCampaign($campaign, $business);
$newCampaign = $campaign->replicate();
@@ -312,11 +312,11 @@ class CampaignController extends Controller
}
return redirect()
->route('seller.business.marketing.campaigns.edit', [$business->slug, $newCampaign])
->route('seller.business.marketing.campaigns.edit', [$business, $newCampaign])
->with('success', 'Campaign duplicated. You can now edit the copy.');
}
protected function authorizeCampaign(Campaign $campaign, $business): void
protected function authorizeCampaign(Campaign $campaign, Business $business): void
{
if ($campaign->business_id !== $business->id) {
abort(404);
@@ -333,9 +333,8 @@ class CampaignController extends Controller
/**
* Generate campaign messaging content using AI.
*/
public function aiGenerate(Request $request, $businessSlug, Campaign $campaign): JsonResponse
public function aiGenerate(Request $request, Business $business, Campaign $campaign): JsonResponse
{
$business = currentBusiness();
$this->authorizeCampaign($campaign, $business);
$validated = $request->validate([
@@ -346,7 +345,7 @@ class CampaignController extends Controller
// Get brand for voice context
$brand = $campaign->brand_id
? Brand::find($campaign->brand_id)
: $business->brands()->first();
: Brand::where('business_id', $business->id)->first();
if (! $brand) {
return response()->json([
@@ -408,9 +407,8 @@ class CampaignController extends Controller
/**
* Suggest templates based on campaign type.
*/
public function aiSuggestTemplates(Request $request, $businessSlug, Campaign $campaign): JsonResponse
public function aiSuggestTemplates(Request $request, Business $business, Campaign $campaign): JsonResponse
{
$business = currentBusiness();
$this->authorizeCampaign($campaign, $business);
// Get templates matching campaign type

View File

@@ -3,15 +3,14 @@
namespace App\Http\Controllers\Seller\Marketing;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Marketing\MarketingChannel;
use Illuminate\Http\Request;
class ChannelController extends Controller
{
public function index(Request $request)
public function index(Request $request, Business $business)
{
$business = currentBusiness();
$channels = MarketingChannel::forBusiness($business->id)
->when($request->type, fn ($q, $type) => $q->where('type', $type))
->orderBy('type')
@@ -23,9 +22,8 @@ class ChannelController extends Controller
return view('seller.marketing.channels.index', compact('business', 'channels', 'channelTypes'));
}
public function create(Request $request)
public function create(Request $request, Business $business)
{
$business = currentBusiness();
$channelTypes = MarketingChannel::getTypes();
$providers = MarketingChannel::getProviders();
$preselectedType = $request->query('type');
@@ -38,9 +36,8 @@ class ChannelController extends Controller
));
}
public function store(Request $request)
public function store(Request $request, Business $business)
{
$business = currentBusiness();
$validated = $request->validate([
'type' => 'required|in:'.implode(',', array_keys(MarketingChannel::getTypes())),
@@ -76,9 +73,8 @@ class ChannelController extends Controller
->with('success', 'Channel created successfully.');
}
public function edit(Request $request, $businessSlug, MarketingChannel $channel)
public function edit(Request $request, Business $business, MarketingChannel $channel)
{
$business = currentBusiness();
$this->authorizeChannel($channel, $business);
$channelTypes = MarketingChannel::getTypes();
@@ -92,9 +88,8 @@ class ChannelController extends Controller
));
}
public function update(Request $request, $businessSlug, MarketingChannel $channel)
public function update(Request $request, Business $business, MarketingChannel $channel)
{
$business = currentBusiness();
$this->authorizeChannel($channel, $business);
$validated = $request->validate([
@@ -134,9 +129,8 @@ class ChannelController extends Controller
->with('success', 'Channel updated successfully.');
}
public function destroy(Request $request, $businessSlug, MarketingChannel $channel)
public function destroy(Request $request, Business $business, MarketingChannel $channel)
{
$business = currentBusiness();
$this->authorizeChannel($channel, $business);
$channel->delete();
@@ -146,9 +140,8 @@ class ChannelController extends Controller
->with('success', 'Channel deleted successfully.');
}
public function test(Request $request, $businessSlug, MarketingChannel $channel)
public function test(Request $request, Business $business, MarketingChannel $channel)
{
$business = currentBusiness();
$this->authorizeChannel($channel, $business);
$result = $channel->test();
@@ -160,7 +153,7 @@ class ChannelController extends Controller
return back()->with('error', $result['message']);
}
protected function authorizeChannel(MarketingChannel $channel, $business): void
protected function authorizeChannel(MarketingChannel $channel, Business $business): void
{
if ($channel->business_id !== $business->id) {
abort(404);

View File

@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Seller\Marketing;
use App\Http\Controllers\Controller;
use App\Models\Brand;
use App\Models\Business;
use App\Models\Marketing\MarketingTemplate;
use App\Services\AI\TemplatePromptBuilder;
use App\Services\Marketing\AIContentService;
@@ -15,10 +16,8 @@ use Illuminate\Support\Facades\Log;
class TemplateController extends Controller
{
public function index(Request $request)
public function index(Request $request, Business $business)
{
$business = currentBusiness();
$templates = MarketingTemplate::forBusiness($business->id)
->with(['brand', 'creator'])
->when($request->brand_id, fn ($q, $brandId) => $q->where('brand_id', $brandId))
@@ -27,7 +26,7 @@ class TemplateController extends Controller
->latest()
->paginate(20);
$brands = $business->brands()->orderBy('name')->get();
$brands = Brand::where('business_id', $business->id)->orderBy('name')->get();
$categories = MarketingTemplate::getCategories();
$channelTypes = MarketingTemplate::getChannelTypes();
@@ -40,10 +39,9 @@ class TemplateController extends Controller
));
}
public function create(Request $request)
public function create(Request $request, Business $business)
{
$business = currentBusiness();
$brands = $business->brands()->orderBy('name')->get();
$brands = Brand::where('business_id', $business->id)->orderBy('name')->get();
$categories = MarketingTemplate::getCategories();
$channelTypes = MarketingTemplate::getChannelTypes();
$placeholders = MarketingTemplate::getPlaceholders();
@@ -62,10 +60,8 @@ class TemplateController extends Controller
));
}
public function store(Request $request)
public function store(Request $request, Business $business)
{
$business = currentBusiness();
$validated = $request->validate([
'brand_id' => 'nullable|exists:brands,id',
'name' => 'required|string|max:255',
@@ -95,9 +91,8 @@ class TemplateController extends Controller
->with('success', 'Template created successfully.');
}
public function show(Request $request, $businessSlug, MarketingTemplate $template)
public function show(Request $request, Business $business, MarketingTemplate $template)
{
$business = currentBusiness();
$this->authorizeTemplate($template, $business);
$template->load(['brand', 'creator', 'campaigns']);
@@ -106,12 +101,11 @@ class TemplateController extends Controller
return view('seller.marketing.templates.show', compact('business', 'template', 'placeholders'));
}
public function edit(Request $request, $businessSlug, MarketingTemplate $template)
public function edit(Request $request, Business $business, MarketingTemplate $template)
{
$business = currentBusiness();
$this->authorizeTemplate($template, $business);
$brands = $business->brands()->orderBy('name')->get();
$brands = Brand::where('business_id', $business->id)->orderBy('name')->get();
$categories = MarketingTemplate::getCategories();
$channelTypes = MarketingTemplate::getChannelTypes();
$placeholders = MarketingTemplate::getPlaceholders();
@@ -134,9 +128,8 @@ class TemplateController extends Controller
));
}
public function update(Request $request, $businessSlug, MarketingTemplate $template)
public function update(Request $request, Business $business, MarketingTemplate $template)
{
$business = currentBusiness();
$this->authorizeTemplate($template, $business);
$validated = $request->validate([
@@ -166,9 +159,8 @@ class TemplateController extends Controller
->with('success', 'Template updated successfully.');
}
public function destroy(Request $request, $businessSlug, MarketingTemplate $template)
public function destroy(Request $request, Business $business, MarketingTemplate $template)
{
$business = currentBusiness();
$this->authorizeTemplate($template, $business);
$template->delete();
@@ -181,9 +173,8 @@ class TemplateController extends Controller
/**
* AI Generate - Create new template content from scratch.
*/
public function aiGenerate(Request $request): JsonResponse
public function aiGenerate(Request $request, Business $business): JsonResponse
{
$business = currentBusiness();
$validated = $request->validate([
'brand_id' => 'nullable|exists:brands,id',
@@ -199,7 +190,7 @@ class TemplateController extends Controller
->findOrFail($validated['brand_id']);
} else {
// Use first brand as fallback
$brand = $business->brands()->first();
$brand = Brand::where('business_id', $business->id)->first();
}
if (! $brand) {
@@ -262,9 +253,8 @@ class TemplateController extends Controller
/**
* AI Improve - Enhance existing template content.
*/
public function aiImprove(Request $request, $businessSlug, MarketingTemplate $template): JsonResponse
public function aiImprove(Request $request, Business $business, MarketingTemplate $template): JsonResponse
{
$business = currentBusiness();
$this->authorizeTemplate($template, $business);
$validated = $request->validate([
@@ -275,7 +265,7 @@ class TemplateController extends Controller
// Get brand (use template's brand or fallback)
$brand = $template->brand_id
? Brand::find($template->brand_id)
: $business->brands()->first();
: Brand::where('business_id', $business->id)->first();
if (! $brand) {
return response()->json([
@@ -333,9 +323,8 @@ class TemplateController extends Controller
/**
* AI Subject Lines - Generate email subject line suggestions.
*/
public function aiSubjectLines(Request $request): JsonResponse
public function aiSubjectLines(Request $request, Business $business): JsonResponse
{
$business = currentBusiness();
$validated = $request->validate([
'body' => 'required|string|max:10000',
@@ -384,9 +373,8 @@ class TemplateController extends Controller
/**
* AI Check Spam - Analyze content for spam triggers.
*/
public function aiCheckSpam(Request $request): JsonResponse
public function aiCheckSpam(Request $request, Business $business): JsonResponse
{
$business = currentBusiness();
$validated = $request->validate([
'body' => 'required|string|max:10000',
@@ -427,9 +415,8 @@ class TemplateController extends Controller
/**
* Generate template content using AI with Brand Voice (legacy endpoint).
*/
public function generate(Request $request, $businessSlug, MarketingTemplate $template): JsonResponse
public function generate(Request $request, Business $business, MarketingTemplate $template): JsonResponse
{
$business = currentBusiness();
$this->authorizeTemplate($template, $business);
$validated = $request->validate([
@@ -440,7 +427,7 @@ class TemplateController extends Controller
// Get brand for voice/audience
$brand = $template->brand_id
? Brand::find($template->brand_id)
: $business->brands()->first();
: Brand::where('business_id', $business->id)->first();
if (! $brand) {
return response()->json([
@@ -542,7 +529,7 @@ class TemplateController extends Controller
return $data['content'][0]['text'] ?? '';
}
protected function authorizeTemplate(MarketingTemplate $template, $business): void
protected function authorizeTemplate(MarketingTemplate $template, Business $business): void
{
if ($template->business_id !== $business->id) {
abort(404);

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Seller\Processing;
use App\Http\Controllers\Controller;
use App\Models\Business;
use Illuminate\Http\Request;
class WashReportController extends Controller
@@ -10,10 +11,8 @@ class WashReportController extends Controller
/**
* Display list of wash reports.
*/
public function index(Request $request)
public function index(Request $request, Business $business)
{
$business = $request->user()->business;
// TODO: Implement wash reports listing
// This will show all wash batches for the business
@@ -25,10 +24,8 @@ class WashReportController extends Controller
/**
* Display active washes dashboard.
*/
public function activeDashboard(Request $request)
public function activeDashboard(Request $request, Business $business)
{
$business = $request->user()->business;
// TODO: Implement active washes dashboard
// This will show currently active wash batches
@@ -40,10 +37,8 @@ class WashReportController extends Controller
/**
* Display daily performance report.
*/
public function dailyPerformance(Request $request)
public function dailyPerformance(Request $request, Business $business)
{
$business = $request->user()->business;
// TODO: Implement daily performance reporting
// This will show wash performance metrics for today
@@ -55,10 +50,8 @@ class WashReportController extends Controller
/**
* Display historical wash search.
*/
public function search(Request $request)
public function search(Request $request, Business $business)
{
$business = $request->user()->business;
// TODO: Implement wash report search
// This will allow searching historical wash data

View File

@@ -6,7 +6,9 @@ use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\DeliveryWindow;
use App\Models\Department;
use App\Models\DepartmentSuitePermission;
use App\Models\Driver;
use App\Models\Suite;
use App\Models\User;
use App\Models\Vehicle;
use Illuminate\Http\Request;
@@ -141,11 +143,6 @@ class SettingsController extends Controller
{
$query = $business->users();
// Exclude the business owner from the list
if ($business->owner_user_id) {
$query->where('users.id', '!=', $business->owner_user_id);
}
// Search
if ($request->filled('search')) {
$search = $request->search;
@@ -155,10 +152,15 @@ class SettingsController extends Controller
});
}
// Filter by account type (role)
if ($request->filled('account_type')) {
$query->whereHas('roles', function ($q) use ($request) {
$q->where('name', $request->account_type);
// Filter by role
if ($request->filled('role')) {
$query->wherePivot('role', $request->role);
}
// Filter by department
if ($request->filled('department_id')) {
$query->whereHas('departments', function ($q) use ($request) {
$q->where('departments.id', $request->department_id);
});
}
@@ -170,9 +172,20 @@ class SettingsController extends Controller
$query->where('last_login_at', '<=', $request->last_login_end.' 23:59:59');
}
$users = $query->with('roles')->paginate(15);
$users = $query->with(['roles', 'departments'])->paginate(15);
return view('seller.settings.users', compact('business', 'users'));
// Get all departments for this business
$departments = Department::where('business_id', $business->id)
->orderBy('sort_order')
->get();
// Get business owner info
$owner = $business->owner;
// Get the suites assigned to this business for permission reference
$businessSuites = $business->suites()->active()->get();
return view('seller.settings.users', compact('business', 'users', 'departments', 'owner', 'businessSuites'));
}
/**
@@ -186,7 +199,9 @@ class SettingsController extends Controller
'email' => 'required|email|unique:users,email',
'phone' => 'nullable|string|max:20',
'position' => 'nullable|string|max:255',
'role' => 'required|string|in:company-owner,company-manager,company-user,company-sales,company-accounting,company-manufacturing,company-processing',
'role' => 'required|string|in:owner,admin,manager,member',
'department_ids' => 'nullable|array',
'department_ids.*' => 'exists:departments,id',
'is_point_of_contact' => 'nullable|boolean',
]);
@@ -196,21 +211,32 @@ class SettingsController extends Controller
// Create user and associate with business
$user = \App\Models\User::create([
'name' => $fullName,
'first_name' => $validated['first_name'],
'last_name' => $validated['last_name'],
'email' => $validated['email'],
'phone' => $validated['phone'],
'phone' => $validated['phone'] ?? null,
'position' => $validated['position'] ?? null,
'user_type' => $business->business_type, // Match business type
'password' => bcrypt(str()->random(32)), // Temporary password
]);
// Assign role
$user->assignRole($validated['role']);
// Associate with business with additional pivot data
// Associate with business with role
$business->users()->attach($user->id, [
'role' => $validated['role'],
'is_primary' => false,
'contact_type' => $request->has('is_point_of_contact') ? 'primary' : null,
'permissions' => [], // Empty initially, assigned via department
]);
// Assign to departments if specified
if (! empty($validated['department_ids'])) {
foreach ($validated['department_ids'] as $departmentId) {
$user->departments()->attach($departmentId, [
'role' => 'operator', // Default department role
]);
}
}
// TODO: Send invitation email with password reset link
return redirect()
@@ -264,10 +290,28 @@ class SettingsController extends Controller
->orderBy('sort_order')
->get();
// Define permission categories (existing permissions system)
$permissionCategories = $this->getPermissionCategories();
// Get the suites assigned to this business
$businessSuites = $business->suites()->active()->get();
return view('seller.settings.users-edit', compact('business', 'user', 'isOwner', 'departments', 'permissionCategories'));
// Build suite permissions structure based on business's assigned suites
$suitePermissions = $this->getSuitePermissions($businessSuites);
// Get user's current permissions from pivot
$userPermissions = $business->users()
->where('users.id', $user->id)
->first()
->pivot
->permissions ?? [];
return view('seller.settings.users-edit', compact(
'business',
'user',
'isOwner',
'departments',
'businessSuites',
'suitePermissions',
'userPermissions'
));
}
/**
@@ -324,63 +368,122 @@ class SettingsController extends Controller
}
/**
* Get permission categories for the permissions system.
* Get suite-based permission structure for the assigned suites.
*
* Groups permissions by functional area for better UX.
*/
private function getPermissionCategories(): array
private function getSuitePermissions($businessSuites): array
{
return [
'ecommerce' => [
'name' => 'Ecommerce',
'icon' => 'lucide--shopping-cart',
'permissions' => [
'view_orders' => ['name' => 'View Orders', 'description' => 'View all orders and order details'],
'manage_orders' => ['name' => 'Manage Orders', 'description' => 'Accept, reject, and update orders'],
'view_invoices' => ['name' => 'View Invoices', 'description' => 'View invoices and payment information'],
'manage_invoices' => ['name' => 'Manage Invoices', 'description' => 'Create and edit invoices'],
],
],
'products' => [
'name' => 'Products & Inventory',
'icon' => 'lucide--package',
'permissions' => [
'view_products' => ['name' => 'View Products', 'description' => 'View product catalog'],
'manage_products' => ['name' => 'Manage Products', 'description' => 'Create, edit, and delete products'],
'view_inventory' => ['name' => 'View Inventory', 'description' => 'View inventory levels and stock'],
'manage_inventory' => ['name' => 'Manage Inventory', 'description' => 'Update inventory and stock levels'],
],
],
'data_visibility' => [
'name' => 'Data & Analytics Visibility',
'icon' => 'lucide--eye',
'permissions' => [
'view_sales_data' => ['name' => 'View Sales Data', 'description' => 'See revenue, pricing, profit margins, and sales metrics'],
'view_performance_data' => ['name' => 'View Performance Data', 'description' => 'See yields, efficiency, quality metrics, and production stats'],
'view_cost_data' => ['name' => 'View Cost Data', 'description' => 'See material costs, labor costs, and expense breakdowns'],
'view_customer_data' => ['name' => 'View Customer Data', 'description' => 'See customer names, contact info, and purchase history'],
],
],
'manufacturing' => [
'name' => 'Manufacturing & Processing',
'icon' => 'lucide--factory',
'permissions' => [
'view_work_orders' => ['name' => 'View Work Orders', 'description' => 'View work orders (limited to own department)'],
'manage_work_orders' => ['name' => 'Manage Work Orders', 'description' => 'Create, edit, and complete work orders'],
'view_wash_reports' => ['name' => 'View Wash Reports', 'description' => 'View solventless wash reports and data'],
'manage_wash_reports' => ['name' => 'Manage Wash Reports', 'description' => 'Create and edit wash reports'],
'view_all_departments' => ['name' => 'View All Departments', 'description' => 'See data across all departments (not just own)'],
],
],
'business' => [
'name' => 'Business Management',
'icon' => 'lucide--building-2',
'permissions' => [
'view_settings' => ['name' => 'View Settings', 'description' => 'View business settings and configuration'],
'manage_settings' => ['name' => 'Manage Settings', 'description' => 'Update business settings and configuration'],
'view_users' => ['name' => 'View Users', 'description' => 'View user list and permissions'],
'manage_users' => ['name' => 'Manage Users', 'description' => 'Invite and manage user permissions'],
],
],
$suitePermissions = [];
foreach ($businessSuites as $suite) {
$permissions = DepartmentSuitePermission::getAvailablePermissions($suite->key);
if (empty($permissions)) {
continue;
}
// Group permissions by functional area
$grouped = $this->groupPermissionsByArea($permissions, $suite->key);
$suitePermissions[$suite->key] = [
'name' => $suite->name,
'description' => $suite->description,
'icon' => $suite->icon,
'color' => $suite->color,
'groups' => $grouped,
];
}
return $suitePermissions;
}
/**
* Group permissions by functional area for better organization.
*/
private function groupPermissionsByArea(array $permissions, string $suiteKey): array
{
// Define permission groupings based on prefix patterns
$areaPatterns = [
'dashboard' => ['view_dashboard', 'view_org_dashboard', 'view_analytics', 'view_all_analytics', 'export_analytics'],
'products' => ['view_products', 'manage_products', 'view_inventory', 'adjust_inventory', 'view_costs', 'view_margin'],
'orders' => ['view_orders', 'create_orders', 'manage_orders', 'view_invoices', 'create_invoices'],
'menus' => ['view_menus', 'manage_menus', 'view_promotions', 'manage_promotions'],
'campaigns' => ['view_campaigns', 'manage_campaigns', 'send_campaigns', 'manage_templates'],
'crm' => ['view_pipeline', 'edit_pipeline', 'manage_accounts', 'view_buyer_intelligence'],
'automations' => ['view_automations', 'manage_automations', 'use_copilot'],
'batches' => ['view_batches', 'manage_batches', 'create_batches'],
'processing' => ['view_wash_reports', 'create_wash_reports', 'edit_wash_reports', 'manage_extractions', 'view_yields', 'manage_biomass_intake', 'manage_material_transfers'],
'manufacturing' => ['view_bom', 'manage_bom', 'create_bom', 'view_production_queue', 'manage_production', 'manage_packaging', 'manage_labeling', 'create_skus', 'manage_lot_tracking'],
'work_orders' => ['view_work_orders', 'create_work_orders', 'manage_work_orders'],
'delivery' => ['view_pick_pack', 'manage_pick_pack', 'view_manifests', 'create_manifests', 'view_delivery_windows', 'manage_delivery_windows', 'view_drivers', 'manage_drivers', 'view_vehicles', 'manage_vehicles', 'view_routes', 'manage_routing', 'view_deliveries', 'complete_deliveries', 'manage_proof_of_delivery'],
'compliance' => ['view_compliance', 'manage_licenses', 'manage_coas', 'view_compliance_reports'],
'finance' => ['view_ap', 'manage_ap', 'pay_bills', 'view_ar', 'manage_ar', 'view_budgets', 'manage_budgets', 'approve_budget_exceptions', 'view_inter_company_ledger', 'manage_inter_company', 'view_financial_reports', 'export_financial_data', 'view_forecasting', 'manage_forecasting', 'view_kpis', 'view_usage_billing', 'manage_billing', 'view_cross_business'],
'messaging' => ['view_conversations', 'send_messages', 'manage_contacts'],
'procurement' => ['view_vendors', 'manage_vendors', 'view_requisitions', 'create_requisitions', 'approve_requisitions', 'view_purchase_orders', 'create_purchase_orders', 'receive_goods'],
'tools' => ['manage_settings', 'manage_users', 'manage_departments', 'view_audit_log', 'manage_integrations'],
'marketplace' => ['view_marketplace', 'browse_products', 'manage_cart', 'manage_favorites', 'view_buyer_portal', 'view_account'],
'brand_view' => ['view_sales', 'view_buyers'],
];
$areaLabels = [
'dashboard' => ['name' => 'Dashboard & Analytics', 'icon' => 'lucide--layout-dashboard'],
'products' => ['name' => 'Products & Inventory', 'icon' => 'lucide--package'],
'orders' => ['name' => 'Orders & Invoicing', 'icon' => 'lucide--shopping-cart'],
'menus' => ['name' => 'Menus & Promotions', 'icon' => 'lucide--menu'],
'campaigns' => ['name' => 'Campaigns & Marketing', 'icon' => 'lucide--megaphone'],
'crm' => ['name' => 'CRM & Accounts', 'icon' => 'lucide--users'],
'automations' => ['name' => 'Automations & AI', 'icon' => 'lucide--bot'],
'batches' => ['name' => 'Batches', 'icon' => 'lucide--layers'],
'processing' => ['name' => 'Processing Operations', 'icon' => 'lucide--flask-conical'],
'manufacturing' => ['name' => 'Manufacturing', 'icon' => 'lucide--factory'],
'work_orders' => ['name' => 'Work Orders', 'icon' => 'lucide--clipboard-list'],
'delivery' => ['name' => 'Delivery & Fulfillment', 'icon' => 'lucide--truck'],
'compliance' => ['name' => 'Compliance', 'icon' => 'lucide--shield-check'],
'finance' => ['name' => 'Finance & Budgets', 'icon' => 'lucide--banknote'],
'messaging' => ['name' => 'Messaging', 'icon' => 'lucide--message-square'],
'procurement' => ['name' => 'Procurement', 'icon' => 'lucide--clipboard-list'],
'tools' => ['name' => 'Tools & Settings', 'icon' => 'lucide--settings'],
'marketplace' => ['name' => 'Marketplace', 'icon' => 'lucide--store'],
'brand_view' => ['name' => 'Brand Data', 'icon' => 'lucide--eye'],
];
$grouped = [];
$assigned = [];
foreach ($areaPatterns as $area => $areaPermissions) {
$matchedPermissions = [];
foreach ($permissions as $key => $description) {
if (in_array($key, $areaPermissions) && ! isset($assigned[$key])) {
$matchedPermissions[$key] = $description;
$assigned[$key] = true;
}
}
if (! empty($matchedPermissions)) {
$grouped[$area] = [
'name' => $areaLabels[$area]['name'] ?? ucwords(str_replace('_', ' ', $area)),
'icon' => $areaLabels[$area]['icon'] ?? 'lucide--folder',
'permissions' => $matchedPermissions,
];
}
}
// Add any remaining permissions to "Other"
$remaining = [];
foreach ($permissions as $key => $description) {
if (! isset($assigned[$key])) {
$remaining[$key] = $description;
}
}
if (! empty($remaining)) {
$grouped['other'] = [
'name' => 'Other',
'icon' => 'lucide--more-horizontal',
'permissions' => $remaining,
];
}
return $grouped;
}
/**

View File

@@ -11,11 +11,11 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Represents the permissions a department has for a specific suite.
*
* The `permissions` JSON field contains granular permission flags like:
* - For Sales Suite: view_pipeline, edit_pipeline, manage_accounts, create_orders
* - For Inventory Suite: view_basic, view_costs, adjust_inventory
* - For Finance Suite: view_ap, view_ar, approve_invoices, pay_bills, view_margin
* - etc.
* The `permissions` JSON field contains granular permission flags.
* See SUITE_PERMISSIONS constant for available permissions per suite.
*
* Based on docs/SUITES_AND_PRICING_MODEL.md - 7 active suites:
* - sales, processing, manufacturing, delivery, management, brand_manager, dispensary
*/
class DepartmentSuitePermission extends Model
{
@@ -36,129 +36,317 @@ class DepartmentSuitePermission extends Model
/**
* Available permissions per suite.
* These define what granular permissions are available for each suite.
*
* Note: Messaging, Procurement, and Tools are shared features available to all suites.
* They are included in each suite's permissions for granular control.
*/
public const SUITE_PERMISSIONS = [
// =========================================================================
// SALES SUITE - External customers (priced)
// =========================================================================
'sales' => [
// Dashboard & Analytics
'view_dashboard' => 'View dashboard and overview',
'view_analytics' => 'View analytics and reports',
'export_analytics' => 'Export analytics data',
// Products & Inventory
'view_products' => 'View products',
'manage_products' => 'Create and edit products',
'view_inventory' => 'View inventory levels',
'adjust_inventory' => 'Adjust inventory quantities',
'view_costs' => 'View cost and pricing data',
'view_margin' => 'View margin information',
// Batches (from supplied COAs)
'view_batches' => 'View batch information',
'manage_batches' => 'Manage batch information',
// Orders & Invoicing
'view_orders' => 'View orders',
'create_orders' => 'Create and process orders',
'manage_orders' => 'Manage order status and fulfillment',
'view_invoices' => 'View invoices',
'create_invoices' => 'Create invoices',
// Menus & Promotions
'view_menus' => 'View menus',
'manage_menus' => 'Create and manage menus',
'view_promotions' => 'View promotions',
'manage_promotions' => 'Create and manage promotions',
// Campaigns & Marketing
'view_campaigns' => 'View marketing campaigns',
'manage_campaigns' => 'Create and manage campaigns',
'send_campaigns' => 'Send marketing campaigns',
'manage_templates' => 'Manage message templates',
// CRM & Buyer Intelligence
'view_pipeline' => 'View sales pipeline',
'edit_pipeline' => 'Edit sales pipeline',
'manage_accounts' => 'Manage customer accounts',
'create_orders' => 'Create and process orders',
'view_orders' => 'View orders',
'manage_promotions' => 'Create and manage promotions',
'send_campaigns' => 'Send marketing campaigns',
'manage_templates' => 'Manage message templates',
'view_buyer_intelligence' => 'View buyer intelligence data',
// Automations & AI
'view_automations' => 'View automations',
'manage_automations' => 'Create and manage automations',
'use_copilot' => 'Use AI copilot features',
// Messaging (shared feature)
'view_conversations' => 'View conversations/inbox',
'send_messages' => 'Send messages to customers',
'use_copilot' => 'Use AI copilot features',
'manage_menus' => 'Create and manage menus',
],
'inventory' => [
'view_basic' => 'View basic inventory info',
'view_costs' => 'View cost and pricing data',
'view_margin' => 'View margin information',
'adjust_inventory' => 'Adjust inventory quantities',
'manage_products' => 'Create and edit products',
'manage_batches' => 'Manage batch information',
'manage_components' => 'Manage components and BOMs',
'view_movements' => 'View stock movements',
'create_movements' => 'Create stock movements',
],
'processing' => [
'view_dashboard' => 'View processing dashboard',
'create_wash_reports' => 'Create wash reports',
'edit_wash_reports' => 'Edit wash reports',
'manage_extractions' => 'Manage extraction operations',
'view_yields' => 'View yield reports',
'manage_batches' => 'Manage processing batches',
'manage_work_orders' => 'Manage work orders',
'view_analytics' => 'View processing analytics',
],
'manufacturing' => [
'view_dashboard' => 'View manufacturing dashboard',
'manage_bom' => 'Manage bill of materials',
'create_work_orders' => 'Create work orders',
'manage_work_orders' => 'Manage and complete work orders',
'manage_packaging' => 'Manage packaging operations',
'manage_labeling' => 'Manage labeling',
'view_production_queue' => 'View production queue',
'view_analytics' => 'View manufacturing analytics',
],
'procurement' => [
'manage_contacts' => 'Manage contacts',
// Procurement (shared feature)
'view_vendors' => 'View vendors and suppliers',
'manage_vendors' => 'Manage vendor information',
'view_requisitions' => 'View purchase requisitions',
'create_requisitions' => 'Create purchase requisitions',
'approve_requisitions' => 'Approve purchase requisitions',
'view_purchase_orders' => 'View purchase orders',
'create_purchase_orders' => 'Create purchase orders',
'approve_purchase_orders' => 'Approve purchase orders',
'receive_goods' => 'Receive goods against POs',
'manage_vendors' => 'Manage vendor information',
],
'distribution' => [
'view_pick_pack' => 'View pick/pack queue',
'manage_pick_pack' => 'Manage pick/pack operations',
'view_manifests' => 'View delivery manifests',
'create_manifests' => 'Create delivery manifests',
'manage_routing' => 'Manage delivery routes',
'manage_drivers' => 'Manage drivers and vehicles',
'view_deliveries' => 'View delivery status',
'complete_deliveries' => 'Complete delivery operations',
'view_analytics' => 'View delivery analytics',
],
'finance' => [
'view_ap' => 'View accounts payable',
'manage_ap' => 'Manage accounts payable',
'view_ar' => 'View accounts receivable',
'manage_ar' => 'Manage accounts receivable',
'view_invoices' => 'View invoices',
'create_invoices' => 'Create invoices',
'approve_invoices' => 'Approve invoices',
'pay_bills' => 'Process bill payments',
'view_margin' => 'View margin and profitability',
'view_reports' => 'View financial reports',
'manage_budgets' => 'Manage budgets',
'export_data' => 'Export financial data',
],
'compliance' => [
'view_compliance' => 'View compliance status',
'manage_licenses' => 'Manage licenses',
'manage_coas' => 'Manage certificates of analysis',
'manage_manifests' => 'Manage compliance manifests',
'view_reports' => 'View compliance reports',
'export_data' => 'Export compliance data',
],
'management' => [
'view_org_dashboard' => 'View organization dashboard',
'view_cross_business' => 'View cross-business data',
'view_all_finances' => 'View all financial data (Canopy)',
'manage_forecasting' => 'Manage forecasting',
'view_kpis' => 'View KPIs and metrics',
'manage_settings' => 'Manage organization settings',
],
'inbox' => [
'view_inbox' => 'View inbox messages',
'send_messages' => 'Send messages',
'manage_contacts' => 'Manage contacts',
'use_templates' => 'Use message templates',
],
'tools' => [
// Tools (shared feature)
'manage_settings' => 'Manage business settings',
'manage_users' => 'Manage users and permissions',
'manage_departments' => 'Manage departments',
'view_audit_log' => 'View audit logs',
'manage_integrations' => 'Manage integrations',
],
'brand_portal' => [
'view_sales' => 'View sales and orders for linked brands',
'view_inventory' => 'View inventory for linked brands (read-only)',
'view_promotions' => 'View promotions involving linked brands',
'view_accounts' => 'View customer accounts carrying linked brands',
// Explicitly NOT available for brand portal:
// 'view_costs', 'view_margin', 'edit_anything'
// =========================================================================
// PROCESSING SUITE - Internal (Curagreen, Leopard AZ)
// =========================================================================
'processing' => [
// Dashboard & Analytics
'view_dashboard' => 'View processing dashboard',
'view_analytics' => 'View processing analytics',
// Batches & Runs
'view_batches' => 'View batches and runs',
'manage_batches' => 'Manage processing batches',
'create_batches' => 'Create new batches',
// Extraction Operations
'view_wash_reports' => 'View wash reports',
'create_wash_reports' => 'Create wash reports',
'edit_wash_reports' => 'Edit wash reports',
'manage_extractions' => 'Manage extraction operations',
'view_yields' => 'View yield reports',
// Biomass & Materials
'manage_biomass_intake' => 'Manage biomass intake',
'manage_material_transfers' => 'Manage material transfers',
// Work Orders
'view_work_orders' => 'View work orders',
'create_work_orders' => 'Create work orders',
'manage_work_orders' => 'Manage and complete work orders',
// Compliance (Processing has compliance)
'view_compliance' => 'View compliance status',
'manage_licenses' => 'Manage licenses',
'manage_coas' => 'Manage certificates of analysis',
'view_compliance_reports' => 'View compliance reports',
// Messaging (shared feature)
'view_conversations' => 'View conversations',
'send_messages' => 'Send messages',
'manage_contacts' => 'Manage contacts',
// Procurement (shared feature)
'view_vendors' => 'View vendors and suppliers',
'manage_vendors' => 'Manage vendor information',
'view_requisitions' => 'View purchase requisitions',
'create_requisitions' => 'Create purchase requisitions',
'approve_requisitions' => 'Approve purchase requisitions',
'view_purchase_orders' => 'View purchase orders',
'create_purchase_orders' => 'Create purchase orders',
'receive_goods' => 'Receive goods against POs',
// Tools (shared feature)
'manage_settings' => 'Manage business settings',
'manage_users' => 'Manage users and permissions',
'manage_departments' => 'Manage departments',
'view_audit_log' => 'View audit logs',
],
// =========================================================================
// MANUFACTURING SUITE - Internal (Leopard AZ)
// =========================================================================
'manufacturing' => [
// Dashboard & Analytics
'view_dashboard' => 'View manufacturing dashboard',
'view_analytics' => 'View manufacturing analytics',
// BOM (Manufacturing only)
'view_bom' => 'View bill of materials',
'manage_bom' => 'Manage bill of materials',
'create_bom' => 'Create bill of materials',
// Work Orders
'view_work_orders' => 'View work orders',
'create_work_orders' => 'Create work orders',
'manage_work_orders' => 'Manage and complete work orders',
// Production
'view_production_queue' => 'View production queue',
'manage_production' => 'Manage production operations',
'manage_packaging' => 'Manage packaging operations',
'manage_labeling' => 'Manage labeling',
// SKU & Lot Tracking
'create_skus' => 'Create SKUs from batches',
'manage_lot_tracking' => 'Manage lot tracking',
// Batches
'view_batches' => 'View batch information',
'manage_batches' => 'Manage batch information',
// Compliance (Manufacturing has compliance)
'view_compliance' => 'View compliance status',
'manage_licenses' => 'Manage licenses',
'manage_coas' => 'Manage certificates of analysis',
'view_compliance_reports' => 'View compliance reports',
// Messaging (shared feature)
'view_conversations' => 'View conversations',
'send_messages' => 'Send messages',
'manage_contacts' => 'Manage contacts',
// Procurement (shared feature)
'view_vendors' => 'View vendors and suppliers',
'manage_vendors' => 'Manage vendor information',
'view_requisitions' => 'View purchase requisitions',
'create_requisitions' => 'Create purchase requisitions',
'approve_requisitions' => 'Approve purchase requisitions',
'view_purchase_orders' => 'View purchase orders',
'create_purchase_orders' => 'Create purchase orders',
'receive_goods' => 'Receive goods against POs',
// Tools (shared feature)
'manage_settings' => 'Manage business settings',
'manage_users' => 'Manage users and permissions',
'manage_departments' => 'Manage departments',
'view_audit_log' => 'View audit logs',
],
// =========================================================================
// DELIVERY SUITE - Internal (Leopard AZ)
// =========================================================================
'delivery' => [
// Dashboard & Analytics
'view_dashboard' => 'View delivery dashboard',
'view_analytics' => 'View delivery analytics',
// Pick & Pack
'view_pick_pack' => 'View pick/pack queue',
'manage_pick_pack' => 'Manage pick/pack operations',
// Manifests
'view_manifests' => 'View delivery manifests',
'create_manifests' => 'Create delivery manifests',
// Delivery Windows
'view_delivery_windows' => 'View delivery windows',
'manage_delivery_windows' => 'Manage delivery windows',
// Drivers & Vehicles
'view_drivers' => 'View drivers',
'manage_drivers' => 'Manage drivers',
'view_vehicles' => 'View vehicles',
'manage_vehicles' => 'Manage vehicles',
// Routes & Deliveries
'view_routes' => 'View routes',
'manage_routing' => 'Manage delivery routes',
'view_deliveries' => 'View delivery status',
'complete_deliveries' => 'Complete delivery operations',
'manage_proof_of_delivery' => 'Manage proof of delivery',
// Messaging (shared feature)
'view_conversations' => 'View conversations',
'send_messages' => 'Send messages',
'manage_contacts' => 'Manage contacts',
// Procurement (shared feature)
'view_vendors' => 'View vendors and suppliers',
'manage_vendors' => 'Manage vendor information',
'view_requisitions' => 'View purchase requisitions',
'create_requisitions' => 'Create purchase requisitions',
'approve_requisitions' => 'Approve purchase requisitions',
'view_purchase_orders' => 'View purchase orders',
'create_purchase_orders' => 'Create purchase orders',
'receive_goods' => 'Receive goods against POs',
// Tools (shared feature)
'manage_settings' => 'Manage business settings',
'manage_users' => 'Manage users and permissions',
'manage_departments' => 'Manage departments',
'view_audit_log' => 'View audit logs',
],
// =========================================================================
// MANAGEMENT SUITE - Internal (Canopy - parent company)
// =========================================================================
'management' => [
// Dashboard & Analytics
'view_org_dashboard' => 'View organization dashboard',
'view_cross_business' => 'View cross-business data',
'view_all_analytics' => 'View all analytics across subdivisions',
// Finance - Accounts Payable
'view_ap' => 'View accounts payable',
'manage_ap' => 'Manage accounts payable',
'pay_bills' => 'Process bill payments',
// Finance - Accounts Receivable
'view_ar' => 'View accounts receivable',
'manage_ar' => 'Manage accounts receivable',
'view_invoices' => 'View all invoices',
// Budgets
'view_budgets' => 'View budgets',
'manage_budgets' => 'Manage budgets per subdivision',
'approve_budget_exceptions' => 'Approve POs that exceed budget',
// Inter-company
'view_inter_company_ledger' => 'View inter-company ledger',
'manage_inter_company' => 'Manage inter-company transfers',
// Financial Reports
'view_financial_reports' => 'View financial reports (P&L, balance sheet, cash flow)',
'export_financial_data' => 'Export financial data',
// Forecasting & KPIs
'view_forecasting' => 'View forecasting',
'manage_forecasting' => 'Manage forecasting',
'view_kpis' => 'View KPIs and metrics',
// Usage & Billing
'view_usage_billing' => 'View usage and billing analytics',
'manage_billing' => 'Manage billing',
// Messaging (shared feature)
'view_conversations' => 'View conversations',
'send_messages' => 'Send messages',
'manage_contacts' => 'Manage contacts',
// Tools (shared feature)
'manage_settings' => 'Manage organization settings',
'manage_users' => 'Manage users and permissions',
'manage_departments' => 'Manage departments',
'view_audit_log' => 'View audit logs',
'manage_integrations' => 'Manage integrations',
],
// =========================================================================
// BRAND MANAGER SUITE - External brand partners (view-only)
// =========================================================================
'brand_manager' => [
// View-only access - all data auto-scoped by brand_id
// All data is auto-scoped by brand_id
'view_dashboard' => 'View brand dashboard',
'view_sales' => 'View sales history for assigned brands',
'view_orders' => 'View orders for assigned brands (read-only)',
'view_products' => 'View active products for assigned brands',
@@ -166,10 +354,44 @@ class DepartmentSuitePermission extends Model
'view_promotions' => 'View promotions for assigned brands',
'view_conversations' => 'View conversations for assigned brands (read-only)',
'view_buyers' => 'View buyer accounts for assigned brands (read-only)',
'view_analytics' => 'View brand-level analytics (not internal seller analytics)',
// Explicitly NOT available for brand_manager:
// 'view_costs', 'view_margin', 'view_wholesale_pricing' (unless brand owner)
// 'edit_products', 'create_promotions', 'manage_settings', 'view_other_brands'
'view_analytics' => 'View brand-level analytics',
// Messaging (brand-scoped)
'send_messages' => 'Send messages (brand-scoped)',
// Explicitly NOT available:
// view_costs, view_margin, view_wholesale_pricing
// edit_products, create_promotions, manage_settings, view_other_brands
],
// =========================================================================
// DISPENSARY SUITE - Buyers (dispensaries/retailers)
// =========================================================================
'dispensary' => [
// Marketplace
'view_marketplace' => 'View marketplace',
'browse_products' => 'Browse products',
'view_promotions' => 'View and redeem promotions',
// Ordering
'create_orders' => 'Create orders',
'view_orders' => 'View order history',
'manage_cart' => 'Manage shopping cart',
'manage_favorites' => 'Manage favorite products',
// Buyer Portal
'view_buyer_portal' => 'View buyer portal',
'view_account' => 'View account information',
// Messaging (shared feature)
'view_conversations' => 'View conversations',
'send_messages' => 'Send messages to sellers',
'manage_contacts' => 'Manage contacts',
// Tools (shared feature)
'manage_settings' => 'Manage business settings',
'manage_users' => 'Manage users and permissions',
'view_audit_log' => 'View audit logs',
],
];

View File

@@ -20,6 +20,7 @@ use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;
@@ -43,6 +44,17 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
// Force HTTPS for all generated URLs in non-local environments
// This is required because:
// 1. SSL terminates at the K8s ingress, so PHP sees HTTP
// 2. TrustProxies passes X-Forwarded-Proto, but Laravel's asset() helper
// doesn't automatically use it - it uses the cached URL generator state
// 3. Filament's dynamic imports (tabs.js, select.js) use asset() and fail
// with "Mixed Content" errors if they generate HTTP URLs on HTTPS pages
if (! $this->app->environment('local')) {
URL::forceScheme('https');
}
// Configure password validation defaults
Password::defaults(function () {
return Password::min(8)

View File

@@ -54,6 +54,8 @@ return [
'url' => env('APP_URL', 'http://localhost'),
'asset_url' => env('ASSET_URL'),
/*
|--------------------------------------------------------------------------
| Application Timezone

View File

@@ -11,7 +11,7 @@ class BrandSeeder extends Seeder
/**
* Run the database seeds.
*
* All 14 brands belong to Cannabrands (the seller/manufacturer company)
* All 12 brands belong to Cannabrands (the seller/manufacturer company)
*/
public function run(): void
{
@@ -139,6 +139,6 @@ class BrandSeeder extends Seeder
);
}
$this->command->info('✅ Created 14 brands under Cannabrands business');
$this->command->info('✅ Created 12 brands under Cannabrands business');
}
}

View File

@@ -0,0 +1,168 @@
<?php
namespace Database\Seeders;
use App\Models\Brand;
use App\Models\Product;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
/**
* Development Environment Cleanup Seeder
*
* This seeder is intended to be run manually on the dev.cannabrands.app environment
* to remove test/sample data and keep only Thunder Bud products.
*
* Run with: php artisan db:seed --class=DevCleanupSeeder
*/
class DevCleanupSeeder extends Seeder
{
/**
* Thunder Bud SKU prefix to preserve.
* Only products with SKUs starting with this prefix will be kept.
*/
protected string $thunderBudPrefix = 'TB-';
/**
* Brands to remove (test/sample brands).
*/
protected array $brandsToRemove = [
'bulk',
'twisites', // misspelled version only
];
public function run(): void
{
$this->command->info('Starting dev environment cleanup...');
// Step 1: Remove non-Thunder Bud products
$this->removeNonThunderBudProducts();
// Step 2: Remove test brands (Bulk, Twisties, Twisites)
$this->removeTestBrands();
// Step 3: Clean up orphaned data
$this->cleanupOrphanedData();
$this->command->info('Dev environment cleanup complete!');
}
protected function removeNonThunderBudProducts(): void
{
$this->command->info('Removing non-Thunder Bud products...');
// Find products that DON'T have the TB- prefix
$productsToDelete = Product::where('sku', 'not like', $this->thunderBudPrefix.'%')->get();
$count = $productsToDelete->count();
if ($count === 0) {
$this->command->info('No non-Thunder Bud products found.');
return;
}
$this->command->info("Found {$count} products to remove.");
// Delete related data first (order items, inventory, etc.)
$productIds = $productsToDelete->pluck('id')->toArray();
// Delete order items referencing these products
$orderItemsDeleted = DB::table('order_items')
->whereIn('product_id', $productIds)
->delete();
if ($orderItemsDeleted > 0) {
$this->command->info("Deleted {$orderItemsDeleted} order items.");
}
// Delete inventory records
if (DB::getSchemaBuilder()->hasTable('inventories')) {
$inventoryDeleted = DB::table('inventories')
->whereIn('product_id', $productIds)
->delete();
if ($inventoryDeleted > 0) {
$this->command->info("Deleted {$inventoryDeleted} inventory records.");
}
}
// Delete product variants if they exist
if (DB::getSchemaBuilder()->hasTable('product_variants')) {
$variantsDeleted = DB::table('product_variants')
->whereIn('product_id', $productIds)
->delete();
if ($variantsDeleted > 0) {
$this->command->info("Deleted {$variantsDeleted} product variants.");
}
}
// Delete products
$deleted = Product::whereIn('id', $productIds)->delete();
$this->command->info("Deleted {$deleted} non-Thunder Bud products.");
// List remaining Thunder Bud products
$remaining = Product::count();
$this->command->info("Remaining products: {$remaining}");
}
protected function removeTestBrands(): void
{
$this->command->info('Removing test brands (Bulk, Twisites)...');
$brandsToDelete = Brand::whereIn('slug', $this->brandsToRemove)->get();
if ($brandsToDelete->isEmpty()) {
$this->command->info('No test brands found to remove.');
return;
}
foreach ($brandsToDelete as $brand) {
// Check if brand has products (should have been deleted in previous step)
$productCount = $brand->products()->count();
if ($productCount > 0) {
$this->command->warn("Brand '{$brand->name}' still has {$productCount} products. Deleting them first...");
$brand->products()->delete();
}
$brand->delete();
$this->command->info("Deleted brand: {$brand->name}");
}
}
protected function cleanupOrphanedData(): void
{
$this->command->info('Cleaning up orphaned data...');
// Delete orders with no items
$emptyOrders = DB::table('orders')
->whereNotExists(function ($query) {
$query->select(DB::raw(1))
->from('order_items')
->whereColumn('order_items.order_id', 'orders.id');
})
->delete();
if ($emptyOrders > 0) {
$this->command->info("Deleted {$emptyOrders} empty orders.");
}
// Delete orphaned promotions (if table exists)
if (DB::getSchemaBuilder()->hasTable('promotions')) {
$orphanedPromotions = DB::table('promotions')
->whereNotNull('brand_id')
->whereNotExists(function ($query) {
$query->select(DB::raw(1))
->from('brands')
->whereColumn('brands.id', 'promotions.brand_id');
})
->delete();
if ($orphanedPromotions > 0) {
$this->command->info("Deleted {$orphanedPromotions} orphaned promotions.");
}
}
}
}

View File

@@ -0,0 +1,164 @@
<?php
namespace Database\Seeders;
use App\Models\Brand;
use App\Models\Product;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Storage;
/**
* Development Environment Media Sync Seeder
*
* This seeder updates brand and product media paths in the database
* to match the expected MinIO structure. It does NOT copy files -
* files should be synced separately using mc (MinIO Client) or rsync.
*
* Run with: php artisan db:seed --class=DevMediaSyncSeeder
*
* To sync actual files from local to dev MinIO, use:
* mc mirror local-minio/media/businesses/cannabrands/brands/ dev-minio/media/businesses/cannabrands/brands/
*/
class DevMediaSyncSeeder extends Seeder
{
/**
* Thunder Bud SKU prefix.
*/
protected string $thunderBudPrefix = 'TB-';
public function run(): void
{
$this->command->info('Syncing media paths for dev environment...');
$this->syncBrandMedia();
$this->syncProductMedia();
$this->command->info('Dev media sync complete!');
$this->command->newLine();
$this->command->info('Next steps:');
$this->command->line('1. Sync actual media files to dev MinIO using mc mirror or similar');
$this->command->line('2. Verify images are accessible at the configured AWS_URL');
}
/**
* Update brand logo_path and banner_path to match expected structure.
*/
protected function syncBrandMedia(): void
{
$this->command->info('Syncing brand media paths...');
$brands = Brand::with('business')->get();
$updated = 0;
foreach ($brands as $brand) {
$businessSlug = $brand->business->slug ?? 'cannabrands';
// Set expected paths based on MinIO structure
$basePath = "businesses/{$businessSlug}/brands/{$brand->slug}/branding";
// Try to find actual files or set expected paths
$logoPath = $this->findMediaFile($basePath, 'logo', ['png', 'jpg', 'jpeg']);
$bannerPath = $this->findMediaFile($basePath, 'banner', ['jpg', 'jpeg', 'png']);
// Update if we found paths or if current paths are null
if ($logoPath || $bannerPath || ! $brand->logo_path || ! $brand->banner_path) {
$brand->logo_path = $logoPath ?? "{$basePath}/logo.png";
$brand->banner_path = $bannerPath ?? "{$basePath}/banner.jpg";
$brand->save();
$updated++;
$this->command->line(" - {$brand->name}: logo={$brand->logo_path}, banner={$brand->banner_path}");
}
}
$this->command->info("Updated {$updated} brand media paths.");
}
/**
* Update Thunder Bud product image_path fields.
*/
protected function syncProductMedia(): void
{
$this->command->info('Syncing Thunder Bud product media paths...');
// Find Thunder Bud brand
$thunderBudBrand = Brand::where('slug', 'thunder-bud')
->orWhere('slug', 'thunderbud')
->first();
if (! $thunderBudBrand) {
$this->command->warn('Thunder Bud brand not found.');
return;
}
$businessSlug = $thunderBudBrand->business->slug ?? 'cannabrands';
$brandSlug = $thunderBudBrand->slug;
// Find all Thunder Bud products (TB- prefix only)
$products = Product::where('sku', 'like', $this->thunderBudPrefix.'%')->get();
$updated = 0;
foreach ($products as $product) {
// Set expected image path based on SKU
$imagePath = "businesses/{$businessSlug}/brands/{$brandSlug}/products/{$product->sku}/images";
// Try to find actual image file
$actualImage = $this->findProductImage($imagePath);
if ($actualImage) {
$product->image_path = $actualImage;
$product->save();
$updated++;
$this->command->line(" - {$product->sku}: {$actualImage}");
} else {
// Set a default expected path
$expectedPath = "{$imagePath}/{$product->slug}.png";
$product->image_path = $expectedPath;
$product->save();
$updated++;
$this->command->line(" - {$product->sku}: {$expectedPath} (expected)");
}
}
$this->command->info("Updated {$updated} product media paths.");
}
/**
* Find a media file in MinIO with the given base path and name.
*/
protected function findMediaFile(string $basePath, string $name, array $extensions): ?string
{
foreach ($extensions as $ext) {
$path = "{$basePath}/{$name}.{$ext}";
if (Storage::exists($path)) {
return $path;
}
}
return null;
}
/**
* Find a product image in the given path.
*/
protected function findProductImage(string $imagePath): ?string
{
try {
$files = Storage::files($imagePath);
// Return the first image file found
foreach ($files as $file) {
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
if (in_array($ext, ['png', 'jpg', 'jpeg', 'webp'])) {
return $file;
}
}
} catch (\Exception $e) {
// Directory doesn't exist
}
return null;
}
}

View File

@@ -8,110 +8,183 @@ use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
/**
* Assigns suites and plans to dev businesses for local testing.
* Gives Cannabrands all suites so all features are accessible.
* Assigns suites to businesses based on docs/SUITES_AND_PRICING_MODEL.md
*
* Run after ProductionSyncSeeder.
* Business Suite Mapping:
*
* Cannabrands (Sales & brand representation):
* - Sales Suite
* - is_enterprise_plan = true
*
* Curagreen (Processing BHO extraction):
* - Processing Suite
* - is_enterprise_plan = true
*
* Leopard AZ (Solventless + Manufacturing + Delivery):
* - Processing Suite
* - Manufacturing Suite
* - Delivery Suite
* - is_enterprise_plan = true
*
* Canopy (Parent company financial & management):
* - Management Suite
* - is_enterprise_plan = true
*
* Buyer businesses (dispensaries):
* - Dispensary Suite
*/
class DevSuitesSeeder extends Seeder
{
public function run(): void
{
$this->command->info('Assigning dev suites and plans...');
$this->command->info('Assigning suites to businesses per SUITES_AND_PRICING_MODEL.md...');
$this->assignSuitesToCannbrands();
$this->assignSuitesToOtherBusinesses();
$this->assignPlansToBusinesses();
$this->assignSuitesToCuragreen();
$this->assignSuitesToLeopardAz();
$this->assignSuitesToCanopy();
$this->assignSuitesToBuyers();
$this->command->info('Dev suites seeding complete!');
$this->command->info('Suite assignments complete!');
}
/**
* Cannabrands: Sales Suite + Enterprise Plan
*/
private function assignSuitesToCannbrands(): void
{
$cannabrands = Business::where('slug', 'cannabrands')->first();
if (! $cannabrands) {
$this->command->warn('Cannabrands business not found, skipping suite assignments.');
return;
}
// Give Cannabrands ALL active suites for full feature testing
$suites = Suite::where('is_active', true)->get();
foreach ($suites as $suite) {
DB::table('business_suite')->updateOrInsert(
['business_id' => $cannabrands->id, 'suite_id' => $suite->id],
['created_at' => now(), 'updated_at' => now()]
);
}
$this->command->line(" - Cannabrands: assigned {$suites->count()} suites (all active)");
}
private function assignSuitesToOtherBusinesses(): void
{
// Canopy gets Sales + Inventory (typical seller setup)
$this->assignSuites('canopy', ['Sales Suite', 'Inventory Suite', 'Tools Suite']);
// Curagreen gets Marketing + Sales
$this->assignSuites('curagreen', ['Sales Suite', 'Marketing Suite']);
// Leopard gets minimal setup
$this->assignSuites('leopard-az', ['Sales Suite']);
// GreenLeaf (buyer) gets Dispensary Suite
$this->assignSuites('greenleaf-dispensary', ['Dispensary Suite', 'Inbox Suite']);
}
private function assignSuites(string $businessSlug, array $suiteNames): void
{
$business = Business::where('slug', $businessSlug)->first();
$business = Business::where('slug', 'cannabrands')->first();
if (! $business) {
$this->command->warn('Cannabrands business not found, skipping.');
return;
}
$suites = Suite::whereIn('name', $suiteNames)->get();
// Assign Sales Suite
$this->assignSuitesByKey($business, ['sales']);
foreach ($suites as $suite) {
DB::table('business_suite')->updateOrInsert(
['business_id' => $business->id, 'suite_id' => $suite->id],
['created_at' => now(), 'updated_at' => now()]
);
}
// Enable Enterprise Plan (removes usage limits)
$business->update(['is_enterprise_plan' => true]);
$this->command->line(" - {$business->name}: assigned {$suites->count()} suites");
$this->command->line(' ✓ Cannabrands: Sales Suite + Enterprise Plan');
}
private function assignPlansToBusinesses(): void
/**
* Curagreen: Processing Suite + Enterprise Plan
*/
private function assignSuitesToCuragreen(): void
{
$this->command->info('Assigning plans to businesses...');
$business = Business::where('slug', 'curagreen')->first();
// Check if businesses table has plan_id column
if (! \Schema::hasColumn('businesses', 'plan_id')) {
$this->command->warn(' - businesses.plan_id column not found, skipping plan assignments.');
if (! $business) {
$this->command->warn('Curagreen business not found, skipping.');
return;
}
$plans = [
'cannabrands' => 'scale', // Full access for testing
'canopy' => 'growth', // Mid-tier
'curagreen' => 'growth', // Mid-tier
'leopard-az' => 'starter', // Basic
'greenleaf-dispensary' => 'starter',
'phoenix-cannabis-collective' => 'starter',
// Assign Processing Suite
$this->assignSuitesByKey($business, ['processing']);
// Enable Enterprise Plan
$business->update(['is_enterprise_plan' => true]);
$this->command->line(' ✓ Curagreen: Processing Suite + Enterprise Plan');
}
/**
* Leopard AZ: Processing + Manufacturing + Delivery + Enterprise Plan
*/
private function assignSuitesToLeopardAz(): void
{
$business = Business::where('slug', 'leopard-az')->first();
if (! $business) {
$this->command->warn('Leopard AZ business not found, skipping.');
return;
}
// Assign all operational suites for Leopard AZ
$this->assignSuitesByKey($business, [
'processing', // Solventless extraction
'manufacturing', // BOM, packaging, production
'delivery', // Pick/pack, manifests, drivers
]);
// Enable Enterprise Plan
$business->update(['is_enterprise_plan' => true]);
$this->command->line(' ✓ Leopard AZ: Processing + Manufacturing + Delivery + Enterprise Plan');
}
/**
* Canopy: Management Suite + Enterprise Plan
*/
private function assignSuitesToCanopy(): void
{
$business = Business::where('slug', 'canopy')->first();
if (! $business) {
$this->command->warn('Canopy business not found, skipping.');
return;
}
// Assign Management Suite (AP/AR, budgets, cross-business analytics)
$this->assignSuitesByKey($business, ['management']);
// Enable Enterprise Plan
$business->update(['is_enterprise_plan' => true]);
$this->command->line(' ✓ Canopy: Management Suite + Enterprise Plan');
}
/**
* Buyer businesses: Dispensary Suite
*/
private function assignSuitesToBuyers(): void
{
// Common buyer business slugs
$buyerSlugs = [
'greenleaf-dispensary',
'phoenix-cannabis-collective',
];
foreach ($plans as $businessSlug => $planCode) {
$business = Business::where('slug', $businessSlug)->first();
$plan = DB::table('plans')->where('code', $planCode)->first();
foreach ($buyerSlugs as $slug) {
$business = Business::where('slug', $slug)->first();
if ($business && $plan) {
$business->update(['plan_id' => $plan->id]);
$this->command->line(" - {$business->name}: {$plan->name} plan");
if (! $business) {
continue;
}
// Assign Dispensary Suite
$this->assignSuitesByKey($business, ['dispensary']);
$this->command->line("{$business->name}: Dispensary Suite");
}
}
/**
* Helper: Assign suites to a business by suite keys
*/
private function assignSuitesByKey(Business $business, array $suiteKeys): void
{
// First, remove any existing suite assignments for this business
// (to ensure clean state when re-running seeder)
DB::table('business_suite')->where('business_id', $business->id)->delete();
// Assign the specified suites
$suites = Suite::whereIn('key', $suiteKeys)->where('is_active', true)->get();
foreach ($suites as $suite) {
DB::table('business_suite')->insert([
'business_id' => $business->id,
'suite_id' => $suite->id,
'granted_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
}
}
}

View File

@@ -5,290 +5,348 @@ namespace Database\Seeders;
use App\Models\Suite;
use Illuminate\Database\Seeder;
/**
* Seeds the 7 core suites as defined in docs/SUITES_AND_PRICING_MODEL.md
*
* Suites:
* 1. Sales Suite - External customers (priced at $495/mo)
* 2. Processing Suite - Internal (Curagreen, Leopard AZ)
* 3. Manufacturing Suite - Internal (Leopard AZ)
* 4. Delivery Suite - Internal (Leopard AZ)
* 5. Management Suite - Internal (Canopy - parent company)
* 6. Brand Manager Suite - External brand partners (view-only)
* 7. Dispensary Suite - Buyers (dispensaries/retailers)
*
* Note: Enterprise is NOT a suite - it's a flag (is_enterprise_plan) on Business.
*/
class SuitesSeeder extends Seeder
{
public function run(): void
{
$suites = [
// Core sales and marketing
// =====================================================================
// SALES SUITE - External customers (the only priced suite)
// =====================================================================
[
'key' => 'sales',
'name' => 'Sales Suite',
'description' => 'Complete sales platform for wholesalers and brands. Includes analytics, CRM, buyer intelligence, conversations, and AI copilot.',
'description' => 'Complete sales platform for wholesalers and brands. Includes products, inventory, orders, CRM, menus, promotions, campaigns, analytics, buyer intelligence, messaging, and AI copilot.',
'icon' => 'lucide--shopping-cart',
'color' => 'emerald',
'sort_order' => 10,
'is_active' => true,
'is_internal' => false,
'included_features' => [
// Core features
'products',
'inventory',
'orders',
'invoicing',
'menus',
'promotions',
'campaigns',
'analytics',
'crm',
'buyer_intelligence',
'conversations',
'automations',
'copilot',
'orders',
'menus',
],
],
[
'key' => 'marketing',
'name' => 'Marketing Suite',
'description' => 'Marketing tools for campaigns, promotions, and customer engagement.',
'icon' => 'lucide--megaphone',
'color' => 'pink',
'sort_order' => 15,
'is_active' => true,
'is_internal' => false,
'included_features' => [
'campaigns',
'promotions',
'templates',
'analytics',
'batches', // from supplied COAs
// Shared features (all suites get these)
'messaging',
'procurement',
'tools',
],
],
// Operations suites
[
'key' => 'inventory',
'name' => 'Inventory Suite',
'description' => 'Inventory management including stock tracking, costs, movements, and batch management.',
'icon' => 'lucide--package',
'color' => 'cyan',
'sort_order' => 20,
'is_active' => true,
'is_internal' => false,
'included_features' => [
'products',
'stock_tracking',
'costs',
'movements',
'batches',
'components',
],
],
// =====================================================================
// PROCESSING SUITE - Internal (Curagreen, Leopard AZ)
// =====================================================================
[
'key' => 'processing',
'name' => 'Processing Suite',
'description' => 'Processing operations for extractors. Includes wash reports, extraction tracking, yields, and batch management.',
'description' => 'Processing operations for extractors. Includes wash reports, extraction tracking, yields, batches, compliance (licenses, COAs), and processing analytics.',
'icon' => 'lucide--flask-conical',
'color' => 'blue',
'sort_order' => 25,
'sort_order' => 20,
'is_active' => true,
'is_internal' => false,
'is_internal' => true,
'included_features' => [
// Core features
'wash_reports',
'extraction',
'yields',
'batches',
'biomass_intake',
'material_transfers',
'work_orders',
'processing_analytics',
'compliance',
'licenses',
'coas',
// Shared features
'messaging',
'procurement',
'tools',
],
],
// =====================================================================
// MANUFACTURING SUITE - Internal (Leopard AZ)
// =====================================================================
[
'key' => 'manufacturing',
'name' => 'Manufacturing Suite',
'description' => 'Manufacturing operations for producers. Includes work orders, BOMs, packaging, and production management.',
'description' => 'Manufacturing operations for producers. Includes work orders, BOM (Bill of Materials), packaging, labeling, SKU creation, lot tracking, compliance, and production management.',
'icon' => 'lucide--factory',
'color' => 'orange',
'sort_order' => 30,
'is_active' => true,
'is_internal' => false,
'is_internal' => true,
'included_features' => [
// Core features
'work_orders',
'bom',
'bom', // BOM is Manufacturing only
'packaging',
'production',
'labeling',
'sku_creation',
'lot_tracking',
'production_queue',
'manufacturing_analytics',
'compliance',
'licenses',
'coas',
'batches',
// Shared features
'messaging',
'procurement',
'tools',
],
],
// =====================================================================
// DELIVERY SUITE - Internal (Leopard AZ)
// =====================================================================
[
'key' => 'procurement',
'name' => 'Procurement Suite',
'description' => 'Purchasing and vendor management. Includes requisitions, purchase orders, and goods receiving.',
'icon' => 'lucide--clipboard-list',
'color' => 'indigo',
'sort_order' => 35,
'is_active' => true,
'is_internal' => false,
'included_features' => [
'requisitions',
'purchase_orders',
'receiving',
'vendors',
],
],
[
'key' => 'distribution',
'name' => 'Distribution Suite',
'description' => 'Distribution and fulfillment operations. Includes pick/pack, manifests, routes, and proof of delivery.',
'key' => 'delivery',
'name' => 'Delivery Suite',
'description' => 'Delivery and fulfillment operations. Includes pick/pack, manifests, delivery windows, drivers, vehicles, routes, proof of delivery, and delivery analytics.',
'icon' => 'lucide--truck',
'color' => 'violet',
'sort_order' => 40,
'is_active' => true,
'is_internal' => false,
'is_internal' => true,
'included_features' => [
// Core features
'pick_pack',
'manifests',
'routes',
'proof_of_delivery',
'delivery_analytics',
],
],
[
'key' => 'delivery',
'name' => 'Delivery Suite',
'description' => 'Delivery management for order fulfillment. Includes delivery windows, drivers, and vehicles.',
'icon' => 'lucide--calendar-clock',
'color' => 'teal',
'sort_order' => 45,
'is_active' => true,
'is_internal' => false,
'included_features' => [
'delivery_windows',
'drivers',
'vehicles',
'routes',
'proof_of_delivery',
'delivery_analytics',
// Shared features
'messaging',
'procurement',
'tools',
],
],
// Finance and compliance
// =====================================================================
// MANAGEMENT SUITE - Internal (Canopy - parent company)
// =====================================================================
[
'key' => 'finance',
'name' => 'Finance Suite',
'description' => 'Financial management including AP, AR, invoicing, and reporting.',
'icon' => 'lucide--banknote',
'color' => 'green',
'key' => 'management',
'name' => 'Management Suite',
'description' => 'Corporate management and financial oversight for Canopy. Includes AP/AR, budgets, cross-business analytics, inter-company ledger, financial reports, forecasting, and KPIs.',
'icon' => 'lucide--briefcase',
'color' => 'slate',
'sort_order' => 50,
'is_active' => true,
'is_internal' => false,
'is_internal' => true,
'included_features' => [
// Core features
'org_dashboard',
'cross_business_analytics',
'accounts_payable',
'accounts_receivable',
'invoicing',
'reports',
'budgets',
],
],
[
'key' => 'compliance',
'name' => 'Compliance Suite',
'description' => 'Regulatory compliance tools including licenses, COAs, and compliance manifests.',
'icon' => 'lucide--shield-check',
'color' => 'amber',
'sort_order' => 55,
'is_active' => true,
'is_internal' => false,
'included_features' => [
'licenses',
'coas',
'manifests',
'reports',
'budget_approvals',
'inter_company_ledger',
'financial_reports',
'forecasting',
'kpis',
'usage_billing',
// Shared features
'messaging',
'tools',
],
],
// Communication and tools
// =====================================================================
// BRAND MANAGER SUITE - External brand partners (view-only)
// =====================================================================
[
'key' => 'inbox',
'name' => 'Inbox Suite',
'description' => 'Unified messaging and communication tools.',
'icon' => 'lucide--mail',
'color' => 'sky',
'key' => 'brand_manager',
'name' => 'Brand Manager Suite',
'description' => 'View-only portal for external brand teams. Access to assigned brand sales, orders, products, inventory, promotions, conversations, and analytics. All data auto-scoped by brand_id.',
'icon' => 'lucide--user-check',
'color' => 'teal',
'sort_order' => 60,
'is_active' => true,
'is_internal' => false,
'included_features' => [
'messages',
'contacts',
'templates',
],
],
[
'key' => 'tools',
'name' => 'Tools Suite',
'description' => 'Business administration tools including settings, users, departments, and integrations.',
'icon' => 'lucide--settings',
'color' => 'slate',
'sort_order' => 65,
'is_active' => true,
'is_internal' => false,
'included_features' => [
'settings',
'users',
'departments',
'integrations',
'audit_log',
],
],
// Management and enterprise
[
'key' => 'management',
'name' => 'Management Suite',
'description' => 'Corporate management and financial oversight. Includes cross-business analytics, finance, and KPIs.',
'icon' => 'lucide--briefcase',
'color' => 'gray',
'sort_order' => 70,
'is_active' => true,
'is_internal' => false,
'included_features' => [
'cross_business_analytics',
'finance',
'forecasting',
'kpis',
],
],
[
'key' => 'dispensary',
'name' => 'Dispensary Suite',
'description' => 'Retail and dispensary operations. Marketplace access, ordering, and buyer portal.',
'icon' => 'lucide--store',
'color' => 'lime',
'sort_order' => 80,
'is_active' => true,
'is_internal' => false,
'included_features' => [
'marketplace',
'ordering',
'buyer_portal',
'promotions',
],
],
// Brand Manager Suite - External brand team access
[
'key' => 'brand_manager',
'name' => 'Brand Manager',
'description' => 'View-only portal for external brand teams. Access to assigned brand sales, products, inventory, promotions, and conversations. Auto-scoped by brand_id.',
'icon' => 'lucide--user-check',
'color' => 'teal',
'sort_order' => 85,
'is_active' => true,
'is_internal' => false,
'included_features' => [
// Core features (all read-only, brand-scoped)
'view_sales',
'view_orders',
'view_products',
'view_inventory',
'view_promotions',
'view_conversations',
'view_buyers',
'view_analytics',
'brand_scoped', // Flag indicating all data is brand-scoped
// Communication only
'messaging', // Their brand only
// Explicitly NOT available: view_costs, view_margin, edit_*, manage_*
],
],
// Note: Enterprise is deprecated as a suite - it's now a plan limit override.
// Use is_enterprise_plan on Business model instead.
// This entry is kept for reference but marked as deprecated and inactive.
// =====================================================================
// DISPENSARY SUITE - Buyers (dispensaries/retailers)
// =====================================================================
[
'key' => 'dispensary',
'name' => 'Dispensary Suite',
'description' => 'For buyer businesses (dispensaries, retailers). Includes marketplace access, ordering, buyer portal, and promotions.',
'icon' => 'lucide--store',
'color' => 'lime',
'sort_order' => 70,
'is_active' => true,
'is_internal' => false,
'included_features' => [
// Core features
'marketplace',
'ordering',
'buyer_portal',
'promotions',
'favorites',
// Shared features
'messaging',
'tools',
],
],
// =====================================================================
// DEPRECATED SUITES - Marked inactive, kept for reference
// =====================================================================
[
'key' => 'marketing',
'name' => 'Marketing Suite (DEPRECATED)',
'description' => 'DEPRECATED: Merged into Sales Suite. Marketing features are now part of Sales.',
'icon' => 'lucide--megaphone',
'color' => 'pink',
'sort_order' => 900,
'is_active' => false,
'is_internal' => true,
'is_deprecated' => true,
'included_features' => ['deprecated'],
],
[
'key' => 'inventory',
'name' => 'Inventory Suite (DEPRECATED)',
'description' => 'DEPRECATED: Merged into Sales Suite. Inventory features are now part of Sales.',
'icon' => 'lucide--package',
'color' => 'cyan',
'sort_order' => 901,
'is_active' => false,
'is_internal' => true,
'is_deprecated' => true,
'included_features' => ['deprecated'],
],
[
'key' => 'procurement',
'name' => 'Procurement Suite (DEPRECATED)',
'description' => 'DEPRECATED: Procurement is now a shared feature available to all operational suites.',
'icon' => 'lucide--clipboard-list',
'color' => 'indigo',
'sort_order' => 902,
'is_active' => false,
'is_internal' => true,
'is_deprecated' => true,
'included_features' => ['deprecated'],
],
[
'key' => 'distribution',
'name' => 'Distribution Suite (DEPRECATED)',
'description' => 'DEPRECATED: Merged into Delivery Suite.',
'icon' => 'lucide--truck',
'color' => 'violet',
'sort_order' => 903,
'is_active' => false,
'is_internal' => true,
'is_deprecated' => true,
'included_features' => ['deprecated'],
],
[
'key' => 'finance',
'name' => 'Finance Suite (DEPRECATED)',
'description' => 'DEPRECATED: Merged into Management Suite. Finance features are now part of Management (Canopy).',
'icon' => 'lucide--banknote',
'color' => 'green',
'sort_order' => 904,
'is_active' => false,
'is_internal' => true,
'is_deprecated' => true,
'included_features' => ['deprecated'],
],
[
'key' => 'compliance',
'name' => 'Compliance Suite (DEPRECATED)',
'description' => 'DEPRECATED: Compliance features moved to Processing and Manufacturing suites.',
'icon' => 'lucide--shield-check',
'color' => 'amber',
'sort_order' => 905,
'is_active' => false,
'is_internal' => true,
'is_deprecated' => true,
'included_features' => ['deprecated'],
],
[
'key' => 'inbox',
'name' => 'Inbox Suite (DEPRECATED)',
'description' => 'DEPRECATED: Messaging is now a shared feature available to all suites.',
'icon' => 'lucide--mail',
'color' => 'sky',
'sort_order' => 906,
'is_active' => false,
'is_internal' => true,
'is_deprecated' => true,
'included_features' => ['deprecated'],
],
[
'key' => 'tools',
'name' => 'Tools Suite (DEPRECATED)',
'description' => 'DEPRECATED: Tools (settings, users, departments) is now a shared feature available to all suites.',
'icon' => 'lucide--settings',
'color' => 'slate',
'sort_order' => 907,
'is_active' => false,
'is_internal' => true,
'is_deprecated' => true,
'included_features' => ['deprecated'],
],
[
'key' => 'enterprise',
'name' => 'Enterprise Suite (DEPRECATED)',
'description' => 'DEPRECATED: Enterprise is now a plan override (is_enterprise_plan), not a suite. Usage limits are disabled for Enterprise businesses, but feature access is still controlled by suites and departments.',
'description' => 'DEPRECATED: Enterprise is now a plan override flag (is_enterprise_plan) on Business, not a suite.',
'icon' => 'lucide--crown',
'color' => 'gold',
'sort_order' => 100,
'is_active' => false, // Disabled - use is_enterprise_plan instead
'sort_order' => 999,
'is_active' => false,
'is_internal' => true,
'is_deprecated' => true, // Marked as deprecated
'included_features' => [
'deprecated',
],
'is_deprecated' => true,
'included_features' => ['deprecated'],
],
];
@@ -298,5 +356,7 @@ class SuitesSeeder extends Seeder
$suite
);
}
$this->command->info('Suites seeded: 7 active, 9 deprecated');
}
}

68
docker/ci/Dockerfile Normal file
View File

@@ -0,0 +1,68 @@
# ============================================
# Pre-built CI Image for Woodpecker Pipeline
# ============================================
# This image has all PHP extensions pre-installed to speed up CI runs.
# Build and push to: code.cannabrands.app/cannabrands/ci-runner:latest
#
# Build command:
# docker build -t code.cannabrands.app/cannabrands/ci-runner:latest -f docker/ci/Dockerfile .
# docker push code.cannabrands.app/cannabrands/ci-runner:latest
#
# Saves ~60-90 seconds per pipeline run by avoiding extension installation.
FROM php:8.3-cli-alpine
LABEL maintainer="CannaBrands DevOps"
LABEL description="Pre-built CI runner with PHP extensions for Woodpecker pipelines"
# Install system dependencies
RUN apk add --no-cache \
git \
zip \
unzip \
libpng-dev \
libjpeg-turbo-dev \
freetype-dev \
libzip-dev \
icu-dev \
icu-data-full \
postgresql-dev \
linux-headers \
bash \
curl
# Install build dependencies (temporary)
RUN apk add --no-cache --virtual .build-deps \
autoconf \
g++ \
make
# Configure and install PHP extensions
RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j$(nproc) \
intl \
pdo \
pdo_pgsql \
pgsql \
zip \
gd \
pcntl \
bcmath \
opcache
# Install Redis extension
RUN pecl install redis \
&& docker-php-ext-enable redis
# Clean up build dependencies
RUN apk del .build-deps \
&& rm -rf /var/cache/apk/*
# Install Composer
COPY --from=composer:2.8 /usr/bin/composer /usr/bin/composer
# Set working directory
WORKDIR /app
# Verify extensions are installed
RUN php -m | grep -E "(intl|pdo_pgsql|gd|pcntl|redis|zip)" && echo "All extensions installed!"

View File

@@ -0,0 +1,549 @@
# Suites & Pricing Model Architecture Overview
## 1. Purpose
This document defines:
- The **Suite architecture** (7 Suites total)
- How each business (Cannabrands, Curagreen, Leopard AZ, Canopy) is mapped to Suites
- How menus/navigation should eventually be suite-driven
- The **pricing and usage model** for the Sales Suite
- **Procurement flow** between subdivisions and Canopy
- **Cross-division access** for users
- Ground rules to prevent breaking existing behavior while we evolve the system
**This is the ground truth for implementation and future design.**
---
## 2. Corporate Structure
Canopy is the parent management company. Cannabrands, Curagreen, and Leopard AZ are subdivisions.
```
┌─────────────────┐
│ CANOPY │
│ (Parent Co) │
│ │
│ • AP/AR │
│ • Finance │
│ • Budgets │
│ • Approvals │
└────────┬────────┘
│ owns
┌────────────────────┼────────────────────┐
│ │ │
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ Curagreen │ │ Leopard AZ │ │ Cannabrands │
│ (Processing) │ │ (Mfg/Deliv) │ │ (Sales) │
│ │ │ │ │ │
│ Operations │ │ Operations │ │ Operations │
│ only │ │ only │ │ only │
└───────────────┘ └───────────────┘ └───────────────┘
```
**Key principle:** All money flows through Canopy. Subdivisions handle operations; Canopy handles finance.
---
## 3. Suites Overview (7 Total)
We model functionality in high-level **Suites**, not scattered feature flags.
| # | Suite | Target User | Priced? |
|---|-------|-------------|---------|
| 1 | **Sales Suite** | External customers (Brands) | Yes - $495/mo |
| 2 | **Processing Suite** | Internal (Curagreen, Leopard AZ) | No |
| 3 | **Manufacturing Suite** | Internal (Leopard AZ) | No |
| 4 | **Delivery Suite** | Internal (Leopard AZ) | No |
| 5 | **Management Suite** | Internal (Canopy) | No |
| 6 | **Brand Manager Suite** | External (brand partners) | No |
| 7 | **Dispensary Suite** | Buyers (dispensaries/retailers) | No |
**Note:** Enterprise Plan is NOT a suite - it's a flag (`is_enterprise_plan`) that removes usage limits.
### 3.1 Sales Suite (Usage-Tiered, Sold to Customers)
The commercial brain for brands and sales orgs.
**Core Features:**
- Products & Inventory
- Orders & Invoicing
- Menus & Promotions
- Campaigns & Marketing
- Buyers & Accounts (CRM)
- Buyer Intelligence
- Analytics
- Automations & Orchestrator
- Copilot / AI Assist
- Batches (from supplied COAs)
**Shared Features (all suites get these):**
- Messaging (omnichannel)
- Procurement (vendors, requisitions, POs, receiving)
- Tools (settings, users, departments, integrations)
**Only the Sales Suite is priced and usage-tiered.**
### 3.2 Processing Suite (Internal / Enterprise Only)
Used by processing entities (e.g., Curagreen, Leopard AZ solventless).
**Core Features:**
- Biomass intake
- Batches and runs
- BHO extraction (Curagreen)
- Solventless extraction washing & pressing (Leopard AZ)
- Yield tracking & conversions
- Material transfers between entities
- Work Orders from Sales → Processing
- Processing analytics
- Compliance (licenses, COAs)
**Shared Features:**
- Messaging
- Procurement
- Tools
### 3.3 Manufacturing Suite (Internal / Enterprise Only)
Used by manufacturing entities (e.g., Leopard AZ packaging & production).
**Core Features:**
- Work Orders from Sales/Processing
- BOM (Bill of Materials) - **Manufacturing only**
- Packaging & labeling
- SKU creation from batches
- Lot tracking
- Production queues & status
- Manufacturing analytics
- Compliance (licenses, COAs)
- Batches
**Shared Features:**
- Messaging
- Procurement
- Tools
### 3.4 Delivery Suite (Internal / Enterprise Only)
Used by delivery/fulfillment operations (e.g., Leopard AZ).
**Core Features:**
- Pick/Pack screens
- Delivery manifests
- Delivery windows
- Drivers & vehicles
- Route management
- Proof of delivery (POD)
- Delivery analytics
**Shared Features:**
- Messaging
- Procurement
- Tools
### 3.5 Management Suite (Internal / Canopy Only)
Used by top-level management (Canopy as parent company).
**Core Features:**
- Org-wide dashboard
- Cross-business analytics
- AP (Accounts Payable) - all vendor bills, PO payments
- AR (Accounts Receivable) - all customer invoices, collections
- Budget Management - set budgets per subdivision
- Budget Exception Approvals - approve POs that exceed budget
- Inter-company Ledger - track transfers between subdivisions
- Financial Reports - P&L, balance sheet, cash flow
- Forecasting
- Operational KPIs
- Usage & billing analytics
**Shared Features:**
- Messaging
- Tools
### 3.6 Brand Manager Suite (External Partners)
View-only portal for external brand teams to see their brand performance.
**Core Features (all read-only, brand-scoped):**
- View sales history
- View orders
- View products
- View inventory status
- View promotions
- View conversations
- View buyer accounts
- View brand-level analytics
**Communication:**
- Messaging (their brand only - can send/receive)
**Explicitly NOT available:**
- View costs, margins, wholesale pricing
- Edit products, create promotions
- Manage settings
- View other brands
### 3.7 Dispensary Suite (Buyers)
For buyer businesses (dispensaries, retailers) accessing the marketplace.
**Core Features:**
- Marketplace access
- Ordering
- Buyer portal
- Promotions (view/redeem)
**Shared Features:**
- Messaging
- Tools
---
## 4. Shared Features (All Suites)
These features are available to ALL operational suites:
### 4.1 Messaging
All suites get omnichannel messaging capabilities.
### 4.2 Procurement
| Feature | Description |
|---------|-------------|
| Vendors & Suppliers | Each subdivision manages their own |
| Requisitions | Request purchases |
| Purchase Orders | Create POs |
| Goods Receiving | Confirm deliveries |
### 4.3 Tools
| Feature | Description |
|---------|-------------|
| Settings | Business settings |
| Users | User management |
| Departments | Department structure |
| Integrations | Third-party integrations |
| Audit Log | Activity tracking |
---
## 5. Procurement & Finance Flow
### 5.1 Who Can Do What
| Role | Create Requisitions | Create POs | Approve Requisitions | Pay (via Canopy) |
|------|---------------------|------------|----------------------|------------------|
| Department Managers | ✅ | ✅ | ✅ (their dept) | ❌ |
| Owners/Admins | ✅ | ✅ | ✅ (all) | ❌ |
| Canopy Finance | ✅ | ✅ | ✅ (budget exceptions) | ✅ |
### 5.2 Flow
**Department Manager or Owner/Admin:**
```
Creates REQUISITION or PO directly
└─▶ Canopy AP pays when invoiced
```
**Budget Exception (PO exceeds subdivision budget):**
```
PO created
└─▶ Flagged as over budget
└─▶ Canopy must approve
└─▶ Canopy AP pays
```
### 5.3 Canopy's Role
Canopy **doesn't approve routine POs** - they just handle the money:
| Feature | Description |
|---------|-------------|
| AP Processing | Pay vendor invoices matched to POs |
| AR Collections | Collect customer payments |
| Budget Management | Set budgets per subdivision |
| Budget Exception Approvals | Only intervene when PO exceeds budget |
| Financial Reporting | Consolidated view across all subdivisions |
---
## 6. Cross-Division Access
Users can access multiple businesses via department assignments.
### 6.1 How It Works
1. **Add user to another business** via `business_user` pivot
2. **Assign them to a department** in that business
3. **Department has suite permissions** that control what they can do
### 6.2 Example
```
Sarah (User)
├── Business: Cannabrands (primary)
│ └── Department: Sales Team
│ └── Suite: Sales (full permissions)
└── Business: Leopard AZ (cross-division)
└── Department: Manufacturing
└── Suite: Manufacturing (manage_bom only)
```
### 6.3 Permission Hierarchy
```
User Permission =
Business Role (owner gets all)
+ Department Permissions (inherited from department's suite permissions)
+ Individual Overrides (grants/revokes specific to user)
```
| Role | Access Level |
|------|--------------|
| **Owner** | Full access to everything |
| **Admin** | Full access except some owner-only actions |
| **Manager** | Department permissions + can manage team |
| **Member** | Department permissions only |
---
## 7. Business → Suite Mapping
### 7.1 Cannabrands
- **Role:** Sales & brand representation
- **Suites:** Sales Suite
- **Plan:** Enterprise (internal)
### 7.2 Curagreen
- **Role:** Processing BHO extraction
- **Suites:** Processing Suite
- **Plan:** Enterprise (internal)
### 7.3 Leopard AZ
- **Role:** Solventless processing + Manufacturing + Delivery
- **Suites:**
- Processing Suite (solventless)
- Manufacturing Suite
- Delivery Suite
- **Plan:** Enterprise (internal)
### 7.4 Canopy
- **Role:** Parent company - financial & management oversight
- **Suites:** Management Suite
- **Plan:** Enterprise (internal)
---
## 8. Suite-Driven Navigation (Target State)
When a user logs in:
1. We determine their **Business**
2. We read the **Suites** assigned to that Business
3. We check user's **Department permissions** for each suite
4. We build the sidebar/navigation based on accessible features
### 8.1 Sales Suite Menu
- Dashboard
- Brands
- Products & Inventory
- Orders
- Menus
- Promotions
- Buyers & Accounts (CRM)
- Conversations
- Campaigns
- Automations
- Copilot
- Analytics
- Settings
### 8.2 Processing Suite Menu
- Dashboard
- Batches / Runs
- Biomass Intake
- Washing / Pressing (solventless)
- Extraction Runs (BHO)
- Yields
- Material Transfers
- Work Orders
- Compliance (Licenses, COAs)
- Processing Analytics
- Settings
### 8.3 Manufacturing Suite Menu
- Dashboard
- Work Orders
- BOM
- Packaging
- Labeling
- SKU Creation
- Lot Tracking
- Production Queue
- Compliance (Licenses, COAs)
- Manufacturing Analytics
- Settings
### 8.4 Delivery Suite Menu
- Dashboard
- Pick & Pack
- Delivery Windows
- Manifests
- Drivers & Vehicles
- Routes
- Proof of Delivery
- Delivery Analytics
- Settings
### 8.5 Management Suite Menu (Canopy)
- Org Dashboard
- Cross-Business Analytics
- Finance
- Accounts Payable
- Accounts Receivable
- Budgets
- Inter-company Ledger
- Forecasting
- Operations Overview
- Usage & Billing
- Settings
### 8.6 Brand Manager Menu
- Brand Dashboard
- Sales History
- Orders
- Products
- Inventory
- Promotions
- Conversations
- Analytics
### 8.7 Dispensary Suite Menu
- Marketplace
- My Orders
- Favorites
- Promotions
- Messages
- Settings
---
## 9. Sales Suite Pricing & Usage Model
### 9.1 Pricing
**Base Sales Suite Plan:**
- **$495 / month**
- Includes **1 brand**
**Additional Brands:**
- **$195 / month** per additional brand
- Each additional brand comes with its own usage allotment
**No public free tier.** Enterprise internal plan is not sold.
### 9.2 Included Features (Per Brand)
Each brand under the Sales Suite gets:
- Full Inventory & Product Management
- Menus & Promotions
- Buyers & Accounts (CRM)
- Conversations & Messaging
- Marketing & Campaign Tools
- Buyer Intelligence
- Analytics (unlimited views)
- Automations & Orchestrator
- Copilot (AI-assisted workflows)
### 9.3 Usage Limits (Per Brand) Initial Targets
| Resource | Limit |
|----------|-------|
| **SKUs** | 15 SKUs per brand |
| **Menu Sends** | 100 menu sends per month |
| **Promotion Impressions** | 1,000 promotion impressions per month |
| **Messaging** | 500 messages per month (SMS, email, in-app, WhatsApp combined) |
| **AI Credits** | 1,000 AI credits per brand per month |
| **Buyer & CRM Contacts** | 1,000 buyers/contacts per brand |
| **Analytics** | Unlimited analytics views |
### 9.4 Add-ons (Future)
- Extra SKU packs (+10, +25, +100)
- Extra menu send packs
- Extra promotion impression packs
- Extra CRM contact packs
- Extra AI credit packs
- Extra messaging volume
---
## 10. Enterprise Plan
**Enterprise is NOT a Suite** - it's a billing/limit override.
Enterprise Plan (`is_enterprise_plan = true`) means:
- **Usage limits are bypassed** (brands, SKUs, contacts, messages, AI credits, etc.)
- **Feature access is still controlled by assigned Suites**
A business with Enterprise Plan still needs actual Suites assigned to determine which features/menus are available.
**Used only for internal operations (Cannabrands, Curagreen, Leopard AZ, Canopy).**
---
## 11. Safety & Backward Compatibility
1. **Existing navigation and behavior must be preserved** until suite-based nav is explicitly enabled.
2. **Changes should be additive:**
- New DB columns for Suites and usage
- New services for Suite→Menu and usage tracking
- Config flag `app.suites.enabled`
3. **Migrations must be reversible.**
4. **Feature-flag toggles** allow:
- Switching between current menu and suite-driven menu
- Disabling suite enforcement if issues arise
5. **Legacy module flags** (`has_crm`, `has_marketing`, etc.) remain in "Advanced Overrides" admin area until fully migrated.
---
## 12. Implementation Phases
| Phase | Description |
|-------|-------------|
| **Phase 1** | ✅ Suites & Docs Define Suites, business mapping, and pricing |
| **Phase 2** | Suite consolidation Reduce to 7 suites, update seeder and permissions |
| **Phase 3** | User Management UI Settings > Users with full permission control |
| **Phase 4** | Suite-Driven Menu Resolver Implement Suite→Menu mapping behind flag |
| **Phase 5** | Usage Counters Track menu sends, messages, AI usage, contacts, SKUs |
| **Phase 6** | Usage Enforcement & Warnings Soft and hard limits, usage dashboards |
| **Phase 7** | Public Pricing Site Marketing-facing pricing components |
---
**This document is the canonical reference for suite architecture.**

162
docs/USER_MANAGEMENT.md Normal file
View File

@@ -0,0 +1,162 @@
# User Management System
## Overview
The user management system in Settings > Users allows business owners and admins to manage team members, their roles, department assignments, and permissions.
## Role Hierarchy
Users are assigned one of four roles that determine their base level of access:
| Role | Access Level | Description |
|------|--------------|-------------|
| **Owner** | Full | Complete access to everything. Cannot be modified. |
| **Admin** | Nearly Full | Full access except owner-only actions (transfer ownership, delete business) |
| **Manager** | Department + Team | Department permissions plus ability to manage team members and approve requests |
| **Member** | Department Only | Standard team member with department-based permissions only |
## Permission System
Permissions are determined by a combination of:
1. **Business Role** - Owner/Admin gets full access
2. **Department Membership** - Controls access to suite features
3. **Department Role** - Determines level within department (operator, lead, supervisor, manager)
4. **Permission Overrides** - Individual grants/revokes for specific users
### Permission Calculation
```
Effective Permissions =
Business Role Permissions
+ Department Suite Permissions (based on membership)
+ Individual Permission Overrides
```
## Department Roles
Within each department, users can have one of four roles:
| Department Role | Description |
|-----------------|-------------|
| **Operator** | Basic access - can view and perform assigned tasks |
| **Lead** | Can assign work to other operators |
| **Supervisor** | Can approve requests and escalations |
| **Manager** | Full department access - can manage all department functions |
## Suite-Based Permissions
Permissions are organized by the suites assigned to the business. Each suite has feature-specific permissions grouped by functional area:
### Sales Suite Permissions
- **Dashboard & Analytics**: view_dashboard, view_analytics, export_analytics
- **Products & Inventory**: view_products, manage_products, view_inventory, adjust_inventory, view_costs, view_margin
- **Orders & Invoicing**: view_orders, create_orders, manage_orders, view_invoices, create_invoices
- **Menus & Promotions**: view_menus, manage_menus, view_promotions, manage_promotions
- **Campaigns & Marketing**: view_campaigns, manage_campaigns, send_campaigns, manage_templates
- **CRM & Accounts**: view_pipeline, edit_pipeline, manage_accounts, view_buyer_intelligence
- **Automations & AI**: view_automations, manage_automations, use_copilot
- **Batches**: view_batches, manage_batches
- **Messaging**: view_conversations, send_messages, manage_contacts
- **Procurement**: view_vendors, manage_vendors, view_requisitions, create_requisitions, approve_requisitions, view_purchase_orders, create_purchase_orders, receive_goods
- **Tools & Settings**: manage_settings, manage_users, manage_departments, view_audit_log, manage_integrations
### Processing Suite Permissions
- **Dashboard & Analytics**: view_dashboard, view_analytics
- **Batches**: view_batches, manage_batches, create_batches
- **Processing Operations**: view_wash_reports, create_wash_reports, edit_wash_reports, manage_extractions, view_yields, manage_biomass_intake, manage_material_transfers
- **Work Orders**: view_work_orders, create_work_orders, manage_work_orders
- **Compliance**: view_compliance, manage_licenses, manage_coas, view_compliance_reports
- **Messaging**: view_conversations, send_messages, manage_contacts
- **Procurement**: (same as Sales)
- **Tools & Settings**: manage_settings, manage_users, manage_departments, view_audit_log
### Manufacturing Suite Permissions
- **Dashboard & Analytics**: view_dashboard, view_analytics
- **Manufacturing**: view_bom, manage_bom, create_bom, view_production_queue, manage_production, manage_packaging, manage_labeling, create_skus, manage_lot_tracking
- **Work Orders**: view_work_orders, create_work_orders, manage_work_orders
- **Batches**: view_batches, manage_batches
- **Compliance**: view_compliance, manage_licenses, manage_coas, view_compliance_reports
- **Messaging**: view_conversations, send_messages, manage_contacts
- **Procurement**: (same as Sales)
- **Tools & Settings**: manage_settings, manage_users, manage_departments, view_audit_log
### Delivery Suite Permissions
- **Dashboard & Analytics**: view_dashboard, view_analytics
- **Delivery & Fulfillment**: view_pick_pack, manage_pick_pack, view_manifests, create_manifests, view_delivery_windows, manage_delivery_windows, view_drivers, manage_drivers, view_vehicles, manage_vehicles, view_routes, manage_routing, view_deliveries, complete_deliveries, manage_proof_of_delivery
- **Messaging**: view_conversations, send_messages, manage_contacts
- **Procurement**: (same as Sales)
- **Tools & Settings**: manage_settings, manage_users, manage_departments, view_audit_log
### Management Suite Permissions (Canopy)
- **Dashboard & Analytics**: view_org_dashboard, view_cross_business, view_all_analytics
- **Finance & Budgets**: view_ap, manage_ap, pay_bills, view_ar, manage_ar, view_budgets, manage_budgets, approve_budget_exceptions, view_inter_company_ledger, manage_inter_company, view_financial_reports, export_financial_data, view_forecasting, manage_forecasting, view_kpis, view_usage_billing, manage_billing
- **Messaging**: view_conversations, send_messages, manage_contacts
- **Tools & Settings**: manage_settings, manage_users, manage_departments, view_audit_log, manage_integrations
### Brand Manager Suite Permissions
- **Dashboard & Analytics**: view_dashboard, view_analytics
- **Brand Data**: view_sales, view_orders, view_products, view_inventory, view_promotions, view_conversations, view_buyers
- **Messaging**: send_messages (brand-scoped)
### Dispensary Suite Permissions
- **Marketplace**: view_marketplace, browse_products, view_promotions, manage_cart, manage_favorites
- **Orders**: create_orders, view_orders
- **Buyer Portal**: view_buyer_portal, view_account
- **Messaging**: view_conversations, send_messages, manage_contacts
- **Tools & Settings**: manage_settings, manage_users, view_audit_log
## UI Components
### Users List Page (`/s/{business}/settings/users`)
- **Owner Card**: Shows business owner with full access badge
- **Search & Filters**: Search by name/email, filter by role, department, and last login date
- **Users Table**: Shows user info, role badge, department badges, last login
- **Add User Modal**: Create new users with role and department assignment
### Edit User Page (`/s/{business}/settings/users/{user}/edit`)
- **User Profile Card**: Avatar, name, email, position
- **Basic Information**: Position, company, contact type
- **Role Assignment**: Radio buttons for member/manager/admin
- **Department Assignments**: Checkboxes with department role selection
- **Permission Overrides**: Tabbed interface by suite, grouped by functional area
## Cross-Division Access
Users can belong to multiple businesses via the `business_user` pivot table. Each business relationship has its own:
- Role (owner, admin, manager, member)
- Contact type (primary, billing, technical)
- Permission overrides
Department assignments are per-business and control suite feature access.
## Implementation Notes
### Database Tables
- `business_user` - Pivot with role, contact_type, permissions columns
- `department_user` - Pivot with department role
- `business_suite` - Links businesses to their assigned suites
- `department_suite_permissions` - Granular permissions per department/suite
### Controller Methods
- `SettingsController::users()` - List users with filters
- `SettingsController::editUser()` - Show edit form with suite permissions
- `SettingsController::updateUser()` - Save role, departments, and permissions
- `SettingsController::inviteUser()` - Create new user with role and departments
- `SettingsController::removeUser()` - Remove user from business
### Permission Checking
Use the `DepartmentSuitePermission::SUITE_PERMISSIONS` constant to get available permissions for each suite. Permissions are stored as a JSON array in the `business_user.permissions` column for individual overrides.

View File

@@ -1,309 +0,0 @@
# Suites & Pricing Model Architecture Overview
## 1. Purpose
This document defines:
- The **Suite architecture** (Sales, Processing, Manufacturing, Delivery, Management, Enterprise).
- How each business (Cannabrands, Curagreen, Leopard AZ, Canopy) is mapped to Suites.
- How menus/navigation should eventually be suite-driven.
- The **pricing and usage model** for the Sales Suite.
- Ground rules to prevent breaking existing behavior while we evolve the system.
**This is the ground truth for implementation and future design.**
---
## 2. Suites Overview
We model functionality in high-level **Suites**, not scattered feature flags.
### 2.1 Sales Suite (Usage-Tiered, Sold to Customers)
The commercial brain for brands and sales orgs. Includes:
- Inventory & Product Catalog
- Basic BOM & Assemblies (product component tracking for B2B marketplace operations)
- Menus & Promotions
- Buyers & Accounts (CRM)
- Conversations & Messaging (omnichannel)
- Marketing & Campaigns
- Buyer Intelligence
- Analytics
- Automations & Orchestrator (sales-side)
- Copilot / AI Assist
- Brand Dashboards & Business Settings
**Only the Sales Suite is priced and usage-tiered.**
### 2.2 Processing Suite (Internal / Enterprise Only)
Used by processing entities (e.g., Curagreen, Leopard AZ solventless).
Includes:
- Biomass intake
- Batches and runs
- BHO extraction (Curagreen)
- Solventless extraction washing & pressing (Leopard AZ)
- Yield tracking & conversions
- Material transfers between entities
- Work Orders from Sales → Processing
- Processing analytics
### 2.3 Manufacturing Suite (Internal / Enterprise Only)
Used by manufacturing entities (e.g., Leopard AZ packaging & production).
Includes:
- Work Orders from Sales/Processing
- BOM (Bill of Materials)
- Packaging & labeling
- SKU creation from batches
- Lot tracking
- Production queues & status
- Manufacturing analytics
### 2.4 Delivery Suite (Internal / Enterprise Only)
Used by delivery/fulfillment operations (e.g., Leopard AZ).
Includes:
- Pick/Pack screens
- Delivery manifests
- Driver / route dashboards
- Proof of delivery (POD)
- Delivery analytics
### 2.5 Management Suite (Internal / Enterprise Only)
Used by top-level management (e.g., Canopy).
Includes:
- Org-wide dashboard
- Cross-business analytics
- Finance / AR / AP views
- Forecasting
- Operational KPIs
- Usage & billing analytics
### 2.6 Enterprise Plan (Internal Only)
**Note:** Enterprise is NOT a Suite - it's a plan/billing override.
Enterprise Plan means:
- **Usage limits are bypassed** (brands, SKUs, contacts, messages, AI credits, etc.)
- **Feature access is still controlled by assigned Suites** (Sales, Processing, Manufacturing, etc.)
A business with Enterprise Plan enabled (`is_enterprise_plan = true`) still needs actual Suites assigned to determine which features/menus are available. Enterprise only removes the usage caps.
**This is not sold; used only for internal operations where usage limits shouldn't apply.**
---
## 3. Business → Suite Mapping
### 3.1 Cannabrands
- **Role:** Sales & brand representation
- **Suites:**
- Sales Suite (full)
- Enterprise Sales access internally (for our own brands)
### 3.2 Curagreen
- **Role:** Processing BHO
- **Suites:**
- Processing Suite
### 3.3 Leopard AZ
- **Role:** Solventless processing + Manufacturing + Delivery
- **Suites:**
- Processing Suite (solventless)
- Manufacturing Suite
- Delivery Suite
### 3.4 Canopy
- **Role:** Financial & management entity
- **Suites:**
- Management Suite
### 3.5 Internal Master / Owner Account
- **Role:** Internal full control
- **Plan:** Enterprise Plan (`is_enterprise_plan = true`)
- **Suites:** All operational suites as needed (Sales, Processing, Manufacturing, etc.)
- **Note:** Enterprise Plan bypasses usage limits; actual feature access controlled by suite assignments
---
## 4. Suite-Driven Navigation (Target State)
### 4.1 Concept
When a user logs in:
1. We determine their **Business**.
2. We read the **Suites** assigned to that Business.
3. We build the sidebar/top navigation based on Suites, not random feature flags.
We eventually want per-suite menus like:
#### Sales Suite Menu (for Cannabrands)
- Dashboard
- Brands
- Inventory
- Menus
- Promotions
- Buyers & Accounts (CRM)
- Conversations
- Messaging
- Automations
- Copilot
- Analytics
- Settings
#### Processing Suite Menu (Curagreen / Leopard AZ Processing)
- Dashboard
- Batches / Runs
- Biomass Intake
- Washing / Pressing (solventless)
- Extraction Runs (BHO)
- Yields
- Material Transfers
- Work Orders
- Processing Analytics
- Settings
#### Manufacturing Suite Menu (Leopard AZ)
- Dashboard
- Work Orders
- BOM
- Packaging
- Labeling
- SKU Creation
- Lot Tracking
- Production Queue
- Manufacturing Analytics
- Settings
#### Delivery Suite Menu (Leopard AZ)
- Dashboard
- Pick & Pack
- Delivery Manifests
- Driver / Route View
- Proof of Delivery
- Delivery Analytics
- Settings
#### Management Suite Menu (Canopy)
- Org Dashboard
- Finance / AR / AP
- Cross-Business Analytics
- Forecasting
- Operations Overview
- Usage & Billing
- Settings
**Important:** While we move toward this, the existing menu remains the default. New suite-driven menus must be gated behind a config flag (e.g., `app.suites.enabled`) and can be turned off if needed.
---
## 5. Sales Suite Pricing & Usage Model
### 5.1 Pricing
**Base Sales Suite Plan:**
- **$495 / month**
- Includes **1 brand**
**Additional Brands:**
- **$195 / month** per additional brand
- Each additional brand comes with its own usage allotment (below).
**No public free tier.** Enterprise internal plan is not sold.
### 5.2 Included Features (Per Brand)
Each brand under the Sales Suite gets:
- Full Inventory & Product Management
- Menus & Promotions
- Buyers & Accounts (CRM)
- Conversations & Messaging
- Marketing & Campaign Tools
- Buyer Intelligence
- Analytics (unlimited views)
- Automations & Orchestrator (sales-side)
- Copilot (AI-assisted workflows)
### 5.3 Usage Limits (Per Brand) Initial Targets
| Resource | Limit |
|----------|-------|
| **SKUs** | 15 SKUs per brand |
| **Menu Sends** | 100 menu sends per month |
| **Promotion Impressions** | 1,000 promotion impressions per month |
| **Messaging** | 500 messages per month (SMS, email, in-app, WhatsApp combined) |
| **AI Credits** | Initial placeholder: 1,000 AI credits per brand per month. Final number to be adjusted based on AI provider cost and token usage analysis. |
| **Buyer & CRM Contacts** | 1,000 buyers/contacts per brand |
| **Analytics** | Unlimited analytics views. Not usage-gated. |
### 5.4 Add-ons (Concept)
Add-on packs (for later implementation):
- Extra SKU packs (e.g., +10, +25, +100)
- Extra menu send packs
- Extra promotion impression packs
- Extra CRM contact packs
- Extra AI credit packs
- Extra messaging volume
---
## 6. Safety & Backward Compatibility Constraints
1. **Existing navigation and behavior must be preserved** until suite-based nav is explicitly enabled.
2. **Changes should be additive:**
- New DB columns for Suites and usage.
- New services for Suite→Menu and usage tracking.
- New config for `app.suites.enabled`.
3. **Migrations must be reversible.**
4. **Feature-flag or config-based toggles** should allow:
- Switching between current menu and suite-driven menu.
- Disabling suite enforcement if issues arise.
5. **Do not remove existing module flags yet;** relegate them to an "Advanced Overrides" admin area.
---
## 7. Implementation Phases
| Phase | Description |
|-------|-------------|
| **Phase 1** | Suites & Docs (this doc) Define Suites, business mapping, and pricing. |
| **Phase 2** | Suite Columns & Admin UI Add suite flags to businesses. Admin can assign Suites to a Business. |
| **Phase 3** | Suite-Driven Menu Resolver (Behind a Flag) Implement Suite→Menu mapping. Allow switching between legacy and suite-based menus via config. |
| **Phase 4** | Usage Counters Track menu sends, messages, AI usage, contacts, and SKUs per brand. |
| **Phase 5** | Usage Enforcement & Warnings Soft and hard limits based on pricing model. Usage dashboards. |
| **Phase 6** | Public Pricing Site & UI Tiles Build marketing-facing pricing components based on `/docs/marketing/PRICING_PAGE_CONTENT.md`. |
---
**This document is the canonical reference for all of the above.**

File diff suppressed because it is too large Load Diff

View File

@@ -117,20 +117,14 @@ docker --version
```
cannabrands-cluster/
├── dev (namespace)
│ ├── cannabrands deployment (1-2 pods)
├── cannabrands-dev (namespace)
│ ├── cannabrands-hub deployment (1 pod)
│ ├── postgresql statefulset
│ ├── redis deployment
│ └── dev.cannabrands.app ingress
── staging (namespace)
├── cannabrands deployment (2-3 pods)
│ ├── postgresql statefulset
│ ├── redis deployment
│ └── staging.cannabrands.app ingress
└── production (namespace)
├── cannabrands deployment (3+ pods, HPA enabled)
── cannabrands-prod (namespace)
├── cannabrands-hub deployment (3+ pods, HPA enabled)
├── postgresql statefulset (with backups)
├── redis deployment (with persistence)
└── cannabrands.app ingress (HTTPS)
@@ -142,8 +136,7 @@ cannabrands-cluster/
┌─────────────────────────────────────────────────────┐
│ Ingress (NGINX) │
│ - dev.cannabrands.app │
│ - staging.cannabrands.app │
│ - cannabrands.app (HTTPS) │
│ - cannabrands.app (HTTPS)
└─────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────┐

View File

@@ -1,6 +1,18 @@
# Kubernetes Deployment Guide
Complete guide for deploying Cannabrands CRM to Kubernetes across dev, staging, and production environments.
Complete guide for deploying Cannabrands CRM to Kubernetes.
## 2-Environment Architecture
Optimized for small teams:
```
localhost (Sail) → develop branch → dev.cannabrands.app
PR to master
cannabrands.app (production)
```
## Table of Contents
@@ -8,12 +20,11 @@ Complete guide for deploying Cannabrands CRM to Kubernetes across dev, staging,
2. [Prerequisites](#prerequisites)
3. [Initial Setup](#initial-setup)
4. [Deploying to Development](#deploying-to-development)
5. [Deploying to Staging](#deploying-to-staging)
6. [Deploying to Production](#deploying-to-production)
7. [Managing Secrets](#managing-secrets)
8. [Database Management](#database-management)
9. [Troubleshooting](#troubleshooting)
10. [Maintenance Tasks](#maintenance-tasks)
5. [Deploying to Production](#deploying-to-production)
6. [Managing Secrets](#managing-secrets)
7. [Database Management](#database-management)
8. [Troubleshooting](#troubleshooting)
9. [Maintenance Tasks](#maintenance-tasks)
---
@@ -33,19 +44,13 @@ k8s/
│ └── kustomization.yaml # Base kustomization
└── overlays/ # Environment-specific configs
├── dev/ # Development overrides
├── dev/ # Development (dev.cannabrands.app)
│ ├── kustomization.yaml
│ ├── deployment-patch.yaml
│ ├── ingress-patch.yaml
│ └── postgres-patch.yaml
── staging/ # Staging overrides
│ ├── kustomization.yaml
│ ├── deployment-patch.yaml
│ ├── ingress-patch.yaml
│ └── postgres-patch.yaml
└── production/ # Production overrides
── production/ # Production (cannabrands.app)
├── kustomization.yaml
├── deployment-patch.yaml
├── ingress-patch.yaml
@@ -54,16 +59,16 @@ k8s/
### Environment Comparison
| Resource | Development | Staging | Production |
|----------|------------|---------|------------|
| **Namespace** | `development` | `staging` | `production` |
| **Domain** | dev.cannabrands.app | staging.cannabrands.app | cannabrands.app |
| **App Replicas** | 1 | 2 | 3 |
| **DB Replicas** | 1 | 1 | 2 (HA) |
| **DB Storage** | 10Gi | 50Gi | 100Gi (SSD) |
| **Docker Image** | `:dev` | `:staging` | `:latest` |
| **Debug Mode** | Enabled | Disabled | Disabled |
| **Log Level** | debug | info | warning |
| Resource | Development | Production |
|----------|-------------|------------|
| **Namespace** | `cannabrands-dev` | `cannabrands-prod` |
| **Domain** | dev.cannabrands.app | cannabrands.app |
| **App Replicas** | 1 | 3 |
| **DB Storage** | 10Gi | 100Gi (SSD) |
| **Docker Image** | `dev-{SHA}` | `prod-{SHA}` |
| **Debug Mode** | Enabled | Disabled |
| **Log Level** | debug | warning |
| **Seeders** | Run on deploy | Never |
---
@@ -255,49 +260,11 @@ Visit: https://dev.cannabrands.app
---
## Deploying to Staging
### 1. Deploy Staging Environment
```bash
kubectl apply -k k8s/overlays/staging
```
### 2. Verify Deployment
```bash
kubectl get all -n staging
kubectl get pods -n staging
```
### 3. Restore Sanitized Production Data
Follow the database strategy guide in `docs/DATABASE_STRATEGY.md`:
```bash
# Run sanitization script (see docs/DATABASE_STRATEGY.md)
./scripts/sanitize-production-for-staging.sh
```
### 4. Run Migrations
```bash
kubectl exec -it deployment/app -n staging -- php artisan migrate --force
```
### 5. Access Staging Site
Point DNS for `staging.cannabrands.app` to cluster ingress IP.
Visit: https://staging.cannabrands.app
---
## Deploying to Production
### 1. Pre-Deployment Checklist
- [ ] Staging is stable and tested
- [ ] Changes tested on dev.cannabrands.app
- [ ] All tests passing in CI
- [ ] Production secrets created and verified
- [ ] Database backup completed (if upgrading)

View File

@@ -68,7 +68,7 @@ spec:
livenessProbe:
httpGet:
path: /
path: /up
port: 80
initialDelaySeconds: 60
periodSeconds: 10
@@ -77,7 +77,7 @@ spec:
readinessProbe:
httpGet:
path: /
path: /up
port: 80
initialDelaySeconds: 15
periodSeconds: 5

View File

@@ -8,9 +8,29 @@ spec:
spec:
initContainers:
- name: migrate
# Image tag updated by CI/CD pipeline via kubectl set image
# Format: prod-{SHA} (e.g., prod-abc1234)
image: code.cannabrands.app/cannabrands/hub:latest
command: ["/bin/sh", "-c"]
args:
- |
echo "Publishing vendor assets..."
php artisan vendor:publish --tag=public --force
echo "Publishing Filament assets..."
php artisan filament:assets
echo "Optimizing Laravel..."
php artisan optimize
echo "Optimizing Filament..."
php artisan filament:optimize
echo "Setting up storage directories..."
mkdir -p /var/www/html/storage/framework/{sessions,views,cache}
chmod -R 775 /var/www/html/storage
echo "Running migrations..."
php artisan migrate --force
echo "Init container complete!"
containers:
- name: app
# Image tag updated by CI/CD pipeline via kubectl set image
image: code.cannabrands.app/cannabrands/hub:latest
resources:
requests:

View File

@@ -1,14 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: cannabrands-hub
spec:
replicas: 2 # 2 replicas for staging
template:
spec:
initContainers:
- name: migrate
image: code.cannabrands.app/cannabrands/hub:staging
containers:
- name: app
image: code.cannabrands.app/cannabrands/hub:staging

View File

@@ -1,20 +0,0 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: cannabrands-hub
spec:
tls:
- hosts:
- staging.cannabrands.app
secretName: staging-tls-secret
rules:
- host: staging.cannabrands.app
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: cannabrands-hub
port:
number: 80

View File

@@ -1,31 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: cannabrands-staging
resources:
- ../../base
patches:
- path: deployment-patch.yaml
- path: ingress-patch.yaml
- path: postgres-patch.yaml
configMapGenerator:
- name: app-config
behavior: merge
literals:
- APP_ENV=staging
- APP_DEBUG=false
- APP_URL=https://staging.cannabrands.app
- ASSET_URL=https://staging.cannabrands.app
- DB_HOST=postgres.cannabrands-staging.svc.cluster.local
- DB_DATABASE=cannabrands_staging
- REDIS_HOST=redis.cannabrands-staging.svc.cluster.local
- LOG_LEVEL=info
- TELESCOPE_ENABLED=true
- name: postgres-config
behavior: merge
literals:
- database=cannabrands_staging

View File

@@ -1,13 +0,0 @@
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgres
spec:
volumeClaimTemplates:
- metadata:
name: postgres-storage
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 50Gi # Medium for staging

View File

@@ -107,33 +107,69 @@
<option value="technical" {{ old('contact_type', $user->businesses->first()->pivot->contact_type ?? '') === 'technical' ? 'selected' : '' }}>Technical Contact</option>
</select>
</div>
<div class="form-control mt-4">
<label class="label">
<span class="label-text font-medium">Role</span>
</label>
<select name="role" class="select select-bordered" {{ $isOwner ? 'disabled' : '' }}>
<option value="staff" {{ old('role', $user->businesses->first()->pivot->role ?? 'staff') === 'staff' ? 'selected' : '' }}>Staff</option>
<option value="manager" {{ old('role', $user->businesses->first()->pivot->role ?? '') === 'manager' ? 'selected' : '' }}>Manager</option>
<option value="admin" {{ old('role', $user->businesses->first()->pivot->role ?? '') === 'admin' ? 'selected' : '' }}>Admin</option>
</select>
</div>
<div class="form-control mt-4">
<label class="label">
<span class="label-text font-medium">Role Template (Optional)</span>
</label>
<select name="role_template" class="select select-bordered" {{ $isOwner ? 'disabled' : '' }}>
<option value="">No Template</option>
<option value="sales" {{ old('role_template', $user->businesses->first()->pivot->role_template ?? '') === 'sales' ? 'selected' : '' }}>Sales</option>
<option value="accounting" {{ old('role_template', $user->businesses->first()->pivot->role_template ?? '') === 'accounting' ? 'selected' : '' }}>Accounting</option>
<option value="manufacturing" {{ old('role_template', $user->businesses->first()->pivot->role_template ?? '') === 'manufacturing' ? 'selected' : '' }}>Manufacturing</option>
<option value="processing" {{ old('role_template', $user->businesses->first()->pivot->role_template ?? '') === 'processing' ? 'selected' : '' }}>Processing</option>
</select>
</div>
</div>
</div>
<!-- Role Assignment -->
@if(!$isOwner)
<div class="card bg-base-100 border border-base-300 mb-6">
<div class="card-body">
<h3 class="card-title text-lg mb-4">
<span class="icon-[heroicons--shield-check] size-5"></span>
Role
</h3>
<p class="text-sm text-base-content/70 mb-4">
The user's role determines their base level of access. Owners have full access, Admins have nearly full access, Managers can manage their team, and Members have department-based permissions.
</p>
@php
$currentRole = old('role', $user->businesses->first()->pivot->role ?? 'member');
@endphp
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<label class="cursor-pointer">
<input type="radio" name="role" value="member" class="peer sr-only" {{ $currentRole === 'member' ? 'checked' : '' }} />
<div class="border-2 border-base-300 rounded-lg p-4 peer-checked:border-primary peer-checked:bg-primary/5 transition-all h-full">
<div class="flex items-start gap-3">
<span class="icon-[heroicons--user] size-5 mt-0.5"></span>
<div>
<div class="font-semibold">Member</div>
<div class="text-xs text-base-content/60 mt-1">Standard team member with department-based permissions only</div>
</div>
</div>
</div>
</label>
<label class="cursor-pointer">
<input type="radio" name="role" value="manager" class="peer sr-only" {{ $currentRole === 'manager' ? 'checked' : '' }} />
<div class="border-2 border-base-300 rounded-lg p-4 peer-checked:border-primary peer-checked:bg-primary/5 transition-all h-full">
<div class="flex items-start gap-3">
<span class="icon-[heroicons--user-group] size-5 mt-0.5"></span>
<div>
<div class="font-semibold">Manager</div>
<div class="text-xs text-base-content/60 mt-1">Department permissions + can manage team and approve requests</div>
</div>
</div>
</div>
</label>
<label class="cursor-pointer">
<input type="radio" name="role" value="admin" class="peer sr-only" {{ $currentRole === 'admin' ? 'checked' : '' }} />
<div class="border-2 border-base-300 rounded-lg p-4 peer-checked:border-primary peer-checked:bg-primary/5 transition-all h-full">
<div class="flex items-start gap-3">
<span class="icon-[heroicons--shield-check] size-5 mt-0.5"></span>
<div>
<div class="font-semibold">Admin</div>
<div class="text-xs text-base-content/60 mt-1">Full access to all features except owner-only actions</div>
</div>
</div>
</div>
</label>
</div>
</div>
</div>
@endif
<!-- Department Assignments -->
@if($departments->count() > 0)
<div class="card bg-base-100 border border-base-300 mb-6">
@@ -142,7 +178,9 @@
<span class="icon-[heroicons--building-office-2] size-5"></span>
Department Assignments
</h3>
<p class="text-sm text-base-content/70 mb-4">Assign this user to specific departments and define their role within each.</p>
<p class="text-sm text-base-content/70 mb-4">
Assign this user to departments and define their role within each. Department membership controls which suite features they can access.
</p>
<div class="space-y-3">
@foreach($departments as $department)
@@ -150,7 +188,7 @@
$isAssigned = $user->departments->contains($department->id);
$userDeptRole = $user->departments->find($department->id)?->pivot->role ?? 'operator';
@endphp
<div class="p-4 border border-base-300 rounded-box">
<div class="p-4 border border-base-300 rounded-box {{ $isAssigned ? 'bg-primary/5 border-primary/30' : '' }}">
<label class="flex items-start gap-3">
<input
type="checkbox"
@@ -159,23 +197,26 @@
class="checkbox checkbox-sm mt-1"
{{ $isAssigned ? 'checked' : '' }}
{{ $isOwner ? 'disabled' : '' }}
onchange="this.closest('.p-4').querySelector('select').disabled = !this.checked"
onchange="this.closest('.p-4').querySelector('select').disabled = !this.checked; this.closest('.p-4').classList.toggle('bg-primary/5', this.checked); this.closest('.p-4').classList.toggle('border-primary/30', this.checked);"
>
<div class="flex-1">
<div class="font-medium">{{ $department->name }}</div>
@if($department->description)
<div class="text-sm text-base-content/60 mt-1">{{ $department->description }}</div>
@endif
<div class="mt-2">
<div class="mt-3">
<label class="label py-1">
<span class="label-text text-xs font-medium">Department Role</span>
</label>
<select
name="departments[{{ $department->id }}][role]"
class="select select-sm select-bordered"
class="select select-sm select-bordered w-full max-w-xs"
{{ !$isAssigned || $isOwner ? 'disabled' : '' }}
>
<option value="operator" {{ $userDeptRole === 'operator' ? 'selected' : '' }}>Operator</option>
<option value="lead" {{ $userDeptRole === 'lead' ? 'selected' : '' }}>Lead</option>
<option value="supervisor" {{ $userDeptRole === 'supervisor' ? 'selected' : '' }}>Supervisor</option>
<option value="manager" {{ $userDeptRole === 'manager' ? 'selected' : '' }}>Manager</option>
<option value="operator" {{ $userDeptRole === 'operator' ? 'selected' : '' }}>Operator - Basic access</option>
<option value="lead" {{ $userDeptRole === 'lead' ? 'selected' : '' }}>Lead - Can assign work</option>
<option value="supervisor" {{ $userDeptRole === 'supervisor' ? 'selected' : '' }}>Supervisor - Can approve requests</option>
<option value="manager" {{ $userDeptRole === 'manager' ? 'selected' : '' }}>Manager - Full department access</option>
</select>
</div>
</div>
@@ -187,49 +228,78 @@
</div>
@endif
<!-- Permissions -->
@if(!$isOwner)
<!-- Suite Permissions -->
@if(!$isOwner && !empty($suitePermissions))
<div class="card bg-base-100 border border-base-300 mb-6">
<div class="card-body">
<h3 class="card-title text-lg mb-4">
<span class="icon-[heroicons--shield-check] size-5"></span>
Permissions
<span class="icon-[heroicons--key] size-5"></span>
Permission Overrides
</h3>
<p class="text-sm text-base-content/70 mb-4">Control what this user can see and do within the system.</p>
<p class="text-sm text-base-content/70 mb-4">
By default, users inherit permissions from their role and department assignments. Use these overrides to grant or revoke specific permissions for this user.
</p>
@php
$userPermissions = old('permissions', $user->businesses->first()->pivot->permissions ?? []);
@endphp
<!-- Suite Tabs -->
<div class="tabs tabs-boxed bg-base-200 mb-4">
@foreach($suitePermissions as $suiteKey => $suite)
<button
type="button"
class="tab {{ $loop->first ? 'tab-active' : '' }}"
onclick="showSuitePermissions('{{ $suiteKey }}')"
id="tab-{{ $suiteKey }}"
>
<span class="icon-[{{ $suite['icon'] }}] size-4 mr-2"></span>
{{ $suite['name'] }}
</button>
@endforeach
</div>
@foreach($permissionCategories as $categoryKey => $category)
<div class="mb-6">
<div class="flex items-center gap-2 mb-3">
<span class="icon-[{{ $category['icon'] }}] size-5"></span>
<h4 class="font-semibold">{{ $category['name'] }}</h4>
<!-- Suite Permission Panels -->
@foreach($suitePermissions as $suiteKey => $suite)
<div
id="panel-{{ $suiteKey }}"
class="suite-panel {{ $loop->first ? '' : 'hidden' }}"
>
<div class="p-4 bg-base-200 rounded-box mb-4">
<div class="flex items-center gap-2 mb-1">
<span class="icon-[{{ $suite['icon'] }}] size-5"></span>
<span class="font-semibold">{{ $suite['name'] }}</span>
</div>
<p class="text-sm text-base-content/70">{{ $suite['description'] }}</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3 pl-7">
@foreach($category['permissions'] as $permKey => $permission)
<label class="flex items-start gap-3 p-3 border border-base-300 rounded-box cursor-pointer hover:bg-base-200/50 transition-colors">
<input
type="checkbox"
name="permissions[]"
value="{{ $permKey }}"
class="checkbox checkbox-sm mt-0.5"
{{ in_array($permKey, $userPermissions) ? 'checked' : '' }}
>
<div class="flex-1">
<div class="font-medium text-sm">{{ $permission['name'] }}</div>
<div class="text-xs text-base-content/60 mt-0.5">{{ $permission['description'] }}</div>
</div>
</label>
@endforeach
</div>
@foreach($suite['groups'] as $groupKey => $group)
<div class="mb-6">
<div class="flex items-center gap-2 mb-3">
<span class="icon-[{{ $group['icon'] }}] size-4 text-base-content/70"></span>
<h5 class="font-semibold text-sm">{{ $group['name'] }}</h5>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-2">
@foreach($group['permissions'] as $permKey => $permDescription)
<label class="flex items-start gap-3 p-3 border border-base-300 rounded-lg cursor-pointer hover:bg-base-200/50 transition-colors">
<input
type="checkbox"
name="permissions[]"
value="{{ $permKey }}"
class="checkbox checkbox-sm mt-0.5"
{{ in_array($permKey, $userPermissions) ? 'checked' : '' }}
>
<div class="flex-1">
<div class="font-medium text-sm">{{ ucwords(str_replace('_', ' ', $permKey)) }}</div>
<div class="text-xs text-base-content/60 mt-0.5">{{ $permDescription }}</div>
</div>
</label>
@endforeach
</div>
</div>
@if(!$loop->last)
<hr class="border-base-300 my-4">
@endif
@endforeach
</div>
@if(!$loop->last)
<hr class="border-base-300 my-4">
@endif
@endforeach
</div>
</div>
@@ -244,7 +314,7 @@
@if(!$isOwner)
<div class="flex gap-2">
<button type="button" onclick="confirm('Are you sure you want to remove this user?') && document.getElementById('delete-form').submit()" class="btn btn-error btn-outline gap-2">
<button type="button" onclick="confirm('Are you sure you want to remove this user from the business?') && document.getElementById('delete-form').submit()" class="btn btn-error btn-outline gap-2">
<span class="icon-[heroicons--user-minus] size-4"></span>
Remove User
</button>
@@ -264,4 +334,24 @@
@method('DELETE')
</form>
@endif
<script>
function showSuitePermissions(suiteKey) {
// Hide all panels
document.querySelectorAll('.suite-panel').forEach(panel => {
panel.classList.add('hidden');
});
// Deactivate all tabs
document.querySelectorAll('.tabs .tab').forEach(tab => {
tab.classList.remove('tab-active');
});
// Show selected panel
document.getElementById('panel-' + suiteKey).classList.remove('hidden');
// Activate selected tab
document.getElementById('tab-' + suiteKey).classList.add('tab-active');
}
</script>
@endsection

View File

@@ -5,22 +5,64 @@
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold">Manage Users</h1>
<p class="text-sm text-base-content/60 mt-1">Manage the permissions for your users.</p>
<p class="text-sm text-base-content/60 mt-1">Manage users, roles, and permissions for your team.</p>
</div>
<div class="flex items-center gap-4">
<div class="breadcrumbs hidden p-0 text-sm sm:inline">
<ul>
<li><a href="{{ route('seller.business.settings.index', $business->slug) }}">Settings</a></li>
<li class="opacity-60">Users & Roles</li>
<li class="opacity-60">Users</li>
</ul>
</div>
<button type="button" class="btn btn-primary gap-2" onclick="add_user_modal.showModal()">
<span class="icon-[heroicons--plus] size-4"></span>
Add users
Add User
</button>
</div>
</div>
@if(session('success'))
<div role="alert" class="alert alert-success mb-6">
<span class="icon-[heroicons--check-circle] size-5"></span>
<span>{{ session('success') }}</span>
</div>
@endif
@if(session('error'))
<div role="alert" class="alert alert-error mb-6">
<span class="icon-[heroicons--exclamation-circle] size-5"></span>
<span>{{ session('error') }}</span>
</div>
@endif
<!-- Business Owner Card -->
@if($owner)
<div class="card bg-base-100 border border-base-300 mb-6">
<div class="card-body">
<div class="flex items-center gap-4">
<div class="avatar placeholder">
<div class="bg-primary text-primary-content w-12 h-12 rounded-full">
<span class="text-lg">{{ strtoupper(substr($owner->first_name ?? $owner->name, 0, 1)) }}{{ strtoupper(substr($owner->last_name ?? '', 0, 1)) }}</span>
</div>
</div>
<div class="flex-1">
<div class="flex items-center gap-2">
<h3 class="font-semibold">{{ $owner->name }}</h3>
<div class="badge badge-primary badge-sm">Owner</div>
</div>
<p class="text-sm text-base-content/70">{{ $owner->email }}</p>
</div>
<div class="text-right text-sm text-base-content/60">
<div>Full access to all features</div>
@if($owner->last_login_at)
<div class="text-xs mt-1">Last login: {{ $owner->last_login_at->diffForHumans() }}</div>
@endif
</div>
</div>
</div>
</div>
@endif
<!-- Search and Filter Section -->
<div class="card bg-base-100 border border-base-300 mb-6">
<div class="card-body">
@@ -43,20 +85,30 @@
<!-- Filter Selectors -->
<div class="flex flex-wrap gap-3">
<!-- Account Type Filter -->
<div class="form-control flex-1 min-w-[200px]">
<select name="account_type" class="select select-bordered select-sm">
<option value="">All Account Types</option>
<option value="company-owner" {{ request('account_type') === 'company-owner' ? 'selected' : '' }}>Owner</option>
<option value="company-manager" {{ request('account_type') === 'company-manager' ? 'selected' : '' }}>Manager</option>
<option value="company-user" {{ request('account_type') === 'company-user' ? 'selected' : '' }}>Staff</option>
<option value="company-sales" {{ request('account_type') === 'company-sales' ? 'selected' : '' }}>Sales</option>
<option value="company-accounting" {{ request('account_type') === 'company-accounting' ? 'selected' : '' }}>Accounting</option>
<option value="company-manufacturing" {{ request('account_type') === 'company-manufacturing' ? 'selected' : '' }}>Manufacturing</option>
<option value="company-processing" {{ request('account_type') === 'company-processing' ? 'selected' : '' }}>Processing</option>
<!-- Role Filter -->
<div class="form-control flex-1 min-w-[150px]">
<select name="role" class="select select-bordered select-sm">
<option value="">All Roles</option>
<option value="admin" {{ request('role') === 'admin' ? 'selected' : '' }}>Admin</option>
<option value="manager" {{ request('role') === 'manager' ? 'selected' : '' }}>Manager</option>
<option value="member" {{ request('role') === 'member' ? 'selected' : '' }}>Member</option>
</select>
</div>
<!-- Department Filter -->
@if($departments->count() > 0)
<div class="form-control flex-1 min-w-[200px]">
<select name="department_id" class="select select-bordered select-sm">
<option value="">All Departments</option>
@foreach($departments as $department)
<option value="{{ $department->id }}" {{ request('department_id') == $department->id ? 'selected' : '' }}>
{{ $department->name }}
</option>
@endforeach
</select>
</div>
@endif
<!-- Last Login Date Range -->
<div class="form-control flex-1 min-w-[150px]">
<input
@@ -103,13 +155,7 @@
<th>
<div class="flex items-center gap-2">
<span class="icon-[heroicons--user] size-4"></span>
Name
</div>
</th>
<th>
<div class="flex items-center gap-2">
<span class="icon-[heroicons--envelope] size-4"></span>
Email
User
</div>
</th>
<th>
@@ -118,6 +164,12 @@
Role
</div>
</th>
<th>
<div class="flex items-center gap-2">
<span class="icon-[heroicons--building-office-2] size-4"></span>
Departments
</div>
</th>
<th>
<div class="flex items-center gap-2">
<span class="icon-[heroicons--clock] size-4"></span>
@@ -129,36 +181,53 @@
</thead>
<tbody>
@foreach($users as $user)
@php
$pivot = $user->pivot;
$userRole = $pivot->role ?? 'member';
$isOwner = $business->owner_user_id === $user->id;
@endphp
<tr class="hover:bg-base-200/50 transition-colors">
<td>
<div class="font-semibold">{{ $user->name }}</div>
<div class="flex items-center gap-3">
<div class="avatar placeholder">
<div class="bg-neutral text-neutral-content w-10 h-10 rounded-full">
<span>{{ strtoupper(substr($user->first_name ?? $user->name, 0, 1)) }}{{ strtoupper(substr($user->last_name ?? '', 0, 1)) }}</span>
</div>
</div>
<div>
<div class="font-semibold">{{ $user->name }}</div>
<div class="text-sm text-base-content/60">{{ $user->email }}</div>
</div>
</div>
</td>
<td>
<div class="text-sm">{{ $user->email }}</div>
</td>
<td>
@if($user->roles->isNotEmpty())
@if($isOwner)
<div class="badge badge-primary">Owner</div>
@else
@php
$roleName = $user->roles->first()->name;
$displayName = match($roleName) {
'company-owner' => 'Owner',
'company-manager' => 'Manager',
'company-user' => 'Staff',
'company-sales' => 'Sales',
'company-accounting' => 'Accounting',
'company-manufacturing' => 'Manufacturing',
'company-processing' => 'Processing',
'buyer-owner' => 'Buyer Owner',
'buyer-manager' => 'Buyer Manager',
'buyer-user' => 'Buyer Staff',
default => ucwords(str_replace('-', ' ', $roleName))
};
$roleColors = [
'admin' => 'badge-secondary',
'manager' => 'badge-accent',
'member' => 'badge-ghost',
];
@endphp
<div class="badge badge-ghost badge-sm">
{{ $displayName }}
<div class="badge {{ $roleColors[$userRole] ?? 'badge-ghost' }}">
{{ ucfirst($userRole) }}
</div>
@endif
</td>
<td>
@if($user->departments->isNotEmpty())
<div class="flex flex-wrap gap-1">
@foreach($user->departments->take(3) as $dept)
<div class="badge badge-outline badge-sm">{{ $dept->name }}</div>
@endforeach
@if($user->departments->count() > 3)
<div class="badge badge-outline badge-sm">+{{ $user->departments->count() - 3 }}</div>
@endif
</div>
@else
<span class="text-base-content/40"></span>
<span class="text-base-content/40">No departments</span>
@endif
</td>
<td>
@@ -171,10 +240,14 @@
</td>
<td>
<div class="flex gap-2 justify-end">
<a href="{{ route('seller.business.settings.users.edit', [$business->slug, $user->uuid]) }}" class="btn btn-sm btn-ghost gap-2">
<span class="icon-[heroicons--pencil] size-4"></span>
Edit
</a>
@if(!$isOwner)
<a href="{{ route('seller.business.settings.users.edit', [$business->slug, $user->uuid]) }}" class="btn btn-sm btn-ghost gap-2">
<span class="icon-[heroicons--pencil] size-4"></span>
Edit
</a>
@else
<span class="text-sm text-base-content/40">Owner</span>
@endif
</div>
</td>
</tr>
@@ -197,10 +270,10 @@
<div class="text-center py-8 text-base-content/60">
<span class="icon-[heroicons--users] size-12 mx-auto mb-2 opacity-30"></span>
<p class="text-sm">
@if(request()->hasAny(['search', 'account_type', 'last_login_start', 'last_login_end']))
@if(request()->hasAny(['search', 'role', 'department_id', 'last_login_start', 'last_login_end']))
No users match your filters. Try adjusting your search criteria.
@else
No users found. Add your first user to get started.
No team members yet. Add your first user to get started.
@endif
</p>
</div>
@@ -208,6 +281,29 @@
</div>
@endif
<!-- Suite Info Card -->
@if($businessSuites->count() > 0)
<div class="card bg-base-100 border border-base-300 mt-6">
<div class="card-body">
<h3 class="card-title text-lg mb-4">
<span class="icon-[heroicons--squares-2x2] size-5"></span>
Active Suites
</h3>
<p class="text-sm text-base-content/70 mb-4">
Your business has access to these suites. User permissions are based on their role and department assignments.
</p>
<div class="flex flex-wrap gap-2">
@foreach($businessSuites as $suite)
<div class="badge badge-lg gap-2" style="background-color: var(--color-{{ $suite->color }}-100, oklch(var(--b2))); color: var(--color-{{ $suite->color }}-800, oklch(var(--bc)));">
<span class="icon-[{{ $suite->icon }}] size-4"></span>
{{ $suite->name }}
</div>
@endforeach
</div>
</div>
</div>
@endif
<!-- Add User Modal -->
<dialog id="add_user_modal" class="modal">
<div class="modal-box max-w-2xl max-h-[90vh] overflow-y-auto">
@@ -226,7 +322,7 @@
<!-- Email -->
<div>
<label class="label">
<span class="label-text font-medium">Email</span>
<span class="label-text font-medium">Email <span class="text-error">*</span></span>
</label>
<input
type="email"
@@ -235,16 +331,13 @@
class="input input-bordered w-full"
placeholder="user@example.com"
/>
<label class="label">
<span class="label-text-alt text-xs text-base-content/60">Add a new or existing user</span>
</label>
</div>
<!-- Name Fields -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium">First Name</span>
<span class="label-text font-medium">First Name <span class="text-error">*</span></span>
</label>
<input
type="text"
@@ -255,7 +348,7 @@
</div>
<div>
<label class="label">
<span class="label-text font-medium">Last Name</span>
<span class="label-text font-medium">Last Name <span class="text-error">*</span></span>
</label>
<input
type="text"
@@ -282,25 +375,13 @@
<!-- Position -->
<div>
<label class="label">
<span class="label-text font-medium">Position</span>
<span class="label-text font-medium">Position / Title</span>
</label>
<input
type="text"
name="position"
class="input input-bordered w-full"
/>
</div>
<!-- Company (Read-only) -->
<div>
<label class="label">
<span class="label-text font-medium">Company</span>
</label>
<input
type="text"
value="{{ $business->name }}"
readonly
class="input input-bordered w-full bg-base-200 text-base-content/60"
placeholder="e.g. Sales Manager"
/>
</div>
</div>
@@ -308,87 +389,108 @@
<hr class="border-base-300 my-6" />
<!-- Account Type Section -->
<!-- Role Section -->
<div class="mb-6">
<h4 class="font-semibold mb-4 text-base">Account Type</h4>
<div class="grid grid-cols-2 gap-3">
<h4 class="font-semibold mb-4 text-base">Role</h4>
<p class="text-sm text-base-content/70 mb-4">
Select the user's role. This determines their base level of access.
</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<label class="cursor-pointer">
<input type="radio" name="role" value="company-user" class="peer sr-only" checked />
<div class="border-2 border-base-300 rounded-lg p-3 peer-checked:border-primary peer-checked:bg-primary/5 transition-all">
<div class="font-semibold">Staff</div>
<input type="radio" name="role" value="member" class="peer sr-only" checked />
<div class="border-2 border-base-300 rounded-lg p-4 peer-checked:border-primary peer-checked:bg-primary/5 transition-all">
<div class="flex items-center gap-3">
<span class="icon-[heroicons--user] size-5"></span>
<div>
<div class="font-semibold">Member</div>
<div class="text-xs text-base-content/60">Standard team member with department-based permissions</div>
</div>
</div>
</div>
</label>
<label class="cursor-pointer">
<input type="radio" name="role" value="company-sales" class="peer sr-only" />
<div class="border-2 border-base-300 rounded-lg p-3 peer-checked:border-primary peer-checked:bg-primary/5 transition-all">
<div class="font-semibold">Sales</div>
<input type="radio" name="role" value="manager" class="peer sr-only" />
<div class="border-2 border-base-300 rounded-lg p-4 peer-checked:border-primary peer-checked:bg-primary/5 transition-all">
<div class="flex items-center gap-3">
<span class="icon-[heroicons--user-group] size-5"></span>
<div>
<div class="font-semibold">Manager</div>
<div class="text-xs text-base-content/60">Can manage team members and approve requests</div>
</div>
</div>
</div>
</label>
<label class="cursor-pointer">
<input type="radio" name="role" value="company-accounting" class="peer sr-only" />
<div class="border-2 border-base-300 rounded-lg p-3 peer-checked:border-primary peer-checked:bg-primary/5 transition-all">
<div class="font-semibold">Accounting</div>
</div>
</label>
<label class="cursor-pointer">
<input type="radio" name="role" value="company-manufacturing" class="peer sr-only" />
<div class="border-2 border-base-300 rounded-lg p-3 peer-checked:border-primary peer-checked:bg-primary/5 transition-all">
<div class="font-semibold">Manufacturing</div>
</div>
</label>
<label class="cursor-pointer">
<input type="radio" name="role" value="company-processing" class="peer sr-only" />
<div class="border-2 border-base-300 rounded-lg p-3 peer-checked:border-primary peer-checked:bg-primary/5 transition-all">
<div class="font-semibold">Processing</div>
</div>
</label>
<label class="cursor-pointer">
<input type="radio" name="role" value="company-manager" class="peer sr-only" />
<div class="border-2 border-base-300 rounded-lg p-3 peer-checked:border-primary peer-checked:bg-primary/5 transition-all">
<div class="font-semibold">Manager</div>
</div>
</label>
<label class="cursor-pointer">
<input type="radio" name="role" value="company-owner" class="peer sr-only" />
<div class="border-2 border-base-300 rounded-lg p-3 peer-checked:border-primary peer-checked:bg-primary/5 transition-all">
<div class="font-semibold">Owner</div>
<input type="radio" name="role" value="admin" class="peer sr-only" />
<div class="border-2 border-base-300 rounded-lg p-4 peer-checked:border-primary peer-checked:bg-primary/5 transition-all">
<div class="flex items-center gap-3">
<span class="icon-[heroicons--shield-check] size-5"></span>
<div>
<div class="font-semibold">Admin</div>
<div class="text-xs text-base-content/60">Full access except owner-only actions</div>
</div>
</div>
</div>
</label>
</div>
<div class="mt-4 p-4 bg-base-200 rounded-box">
</div>
<hr class="border-base-300 my-6" />
<!-- Department Assignment -->
@if($departments->count() > 0)
<div class="mb-6">
<h4 class="font-semibold mb-4 text-base">Department Assignment</h4>
<p class="text-sm text-base-content/70 mb-4">
Assign this user to one or more departments. Department membership controls access to suite features.
</p>
<div class="space-y-2">
@foreach($departments as $department)
<label class="flex items-center gap-3 p-3 border border-base-300 rounded-lg cursor-pointer hover:bg-base-200/50 transition-colors">
<input
type="checkbox"
name="department_ids[]"
value="{{ $department->id }}"
class="checkbox checkbox-sm"
/>
<div class="flex-1">
<div class="font-medium">{{ $department->name }}</div>
@if($department->description)
<div class="text-xs text-base-content/60">{{ $department->description }}</div>
@endif
</div>
</label>
@endforeach
</div>
</div>
<hr class="border-base-300 my-6" />
@endif
<!-- Contact Settings -->
<div class="mb-6">
<div class="p-4 bg-base-200 rounded-box">
<label class="label cursor-pointer justify-start gap-3 p-0">
<input type="checkbox" name="is_point_of_contact" class="checkbox checkbox-sm" />
<div class="flex-1">
<span class="label-text font-medium">Is a point of contact</span>
<p class="text-xs text-base-content/60 mt-1">
If enabled, this user will be automatically listed as a contact for buyers, with their name, job title, email, and phone number visible. If the user is a sales rep, you cannot disable this setting.
If enabled, this user will be listed as a contact for buyers, with their name, job title, email, and phone number visible.
</p>
</div>
</label>
</div>
</div>
<hr class="border-base-300 my-6" />
<!-- Note about permissions -->
<div class="alert bg-base-200 border-base-300 mb-6">
<span class="icon-[heroicons--information-circle] size-5 text-base-content/60"></span>
<div class="text-sm">
<p class="font-semibold">Role-based Access</p>
<p class="text-base-content/70">Permissions are determined by the selected account type. Granular permission controls will be available in a future update.</p>
</div>
</div>
<div class="modal-action">
<button type="button" onclick="add_user_modal.close()" class="btn btn-ghost">Cancel</button>
<button type="submit" class="btn btn-primary gap-2">
Add user
<span class="icon-[heroicons--user-plus] size-4"></span>
Add User
</button>
</div>
</form>
@@ -397,492 +499,4 @@
<button>close</button>
</form>
</dialog>
<!-- Edit User Modals (one per user) -->
@foreach($users as $user)
@php
$nameParts = explode(' ', $user->name, 2);
$firstName = $nameParts[0] ?? '';
$lastName = $nameParts[1] ?? '';
$userRole = $user->roles->first()?->name ?? 'company-user';
$pivot = $user->pivot ?? null;
$isPointOfContact = $pivot && $pivot->contact_type === 'primary';
@endphp
<dialog id="edit_user_modal_{{ $user->id }}" class="modal">
<div class="modal-box max-w-4xl h-[90vh] flex flex-col p-0">
<div class="flex-shrink-0 p-6 pb-4 border-b border-base-300">
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
</form>
<h3 class="font-bold text-lg">Edit User</h3>
</div>
<form method="POST" action="{{ route('seller.business.settings.users.update', ['business' => $business->slug, 'user' => $user->id]) }}" class="flex flex-col flex-1 min-h-0">
@csrf
@method('PATCH')
<div class="flex-1 overflow-y-auto px-6 py-4">
<!-- Account Information Section -->
<div class="mb-6">
<h4 class="font-semibold mb-4 text-base">Account Information</h4>
<div class="space-y-4">
<!-- Email -->
<div>
<label class="label">
<span class="label-text font-medium">Email</span>
</label>
<input
type="email"
name="email"
value="{{ $user->email }}"
required
class="input input-bordered w-full"
/>
</div>
<!-- Name Fields -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium">First Name</span>
</label>
<input
type="text"
name="first_name"
value="{{ $firstName }}"
required
class="input input-bordered w-full"
/>
</div>
<div>
<label class="label">
<span class="label-text font-medium">Last Name</span>
</label>
<input
type="text"
name="last_name"
value="{{ $lastName }}"
required
class="input input-bordered w-full"
/>
</div>
</div>
<!-- Phone Number -->
<div>
<label class="label">
<span class="label-text font-medium">Phone number</span>
</label>
<input
type="tel"
name="phone"
value="{{ $user->phone }}"
class="input input-bordered w-full"
placeholder="(XXX) XXX-XXXX"
/>
</div>
<!-- Position -->
<div>
<label class="label">
<span class="label-text font-medium">Position</span>
</label>
<input
type="text"
name="position"
value="{{ $pivot->position ?? '' }}"
class="input input-bordered w-full"
/>
</div>
<!-- Company (Read-only) -->
<div>
<label class="label">
<span class="label-text font-medium">Company</span>
</label>
<input
type="text"
value="{{ $business->name }}"
readonly
class="input input-bordered w-full bg-base-200 text-base-content/60"
/>
</div>
</div>
</div>
<hr class="border-base-300 my-6" />
<!-- Account Type Section -->
<div class="mb-6">
<h4 class="font-semibold mb-4 text-base">Account Type</h4>
<div>
<label class="label">
<span class="label-text font-medium">Role</span>
</label>
<select name="role" class="select select-bordered w-full" required>
<option value="company-user" {{ $userRole === 'company-user' ? 'selected' : '' }}>Staff</option>
<option value="company-sales" {{ $userRole === 'company-sales' ? 'selected' : '' }}>Sales</option>
<option value="company-accounting" {{ $userRole === 'company-accounting' ? 'selected' : '' }}>Accounting</option>
<option value="company-manufacturing" {{ $userRole === 'company-manufacturing' ? 'selected' : '' }}>Manufacturing</option>
<option value="company-processing" {{ $userRole === 'company-processing' ? 'selected' : '' }}>Processing</option>
<option value="company-manager" {{ $userRole === 'company-manager' ? 'selected' : '' }}>Manager</option>
<option value="company-owner" {{ $userRole === 'company-owner' ? 'selected' : '' }}>Owner</option>
</select>
</div>
<div class="mt-4 p-4 bg-base-200 rounded-box">
<label class="label cursor-pointer justify-start gap-3 p-0">
<input type="checkbox" name="is_point_of_contact" class="checkbox checkbox-sm" {{ $isPointOfContact ? 'checked' : '' }} />
<div class="flex-1">
<span class="label-text font-medium">Is a point of contact</span>
<p class="text-xs text-base-content/60 mt-1">
If enabled, this user will be automatically listed as a contact for buyers, with their name, job title, email, and phone number visible.
</p>
</div>
</label>
</div>
</div>
<hr class="border-base-300 my-6" />
<!-- Permissions Section -->
<div class="mb-6">
<h4 class="font-semibold mb-4 text-base flex items-center gap-2">
<span class="icon-[heroicons--shield-check] size-5"></span>
Permissions
</h4>
<!-- Order & Inventory Management -->
<div class="mb-6">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<span class="icon-[heroicons--cube] size-5"></span>
<h5 class="font-semibold">Order & Inventory Management</h5>
</div>
<label class="label cursor-pointer gap-2 p-0">
<span class="label-text text-sm">Enable All</span>
<input type="checkbox" class="toggle toggle-sm toggle-primary" />
</label>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 pl-7">
<label class="label cursor-pointer justify-start gap-3 p-3 bg-base-100 border border-base-300 rounded-lg">
<input type="checkbox" name="permissions[]" value="manage_inventory" class="checkbox checkbox-sm" />
<div class="flex-1">
<span class="label-text font-medium">Manage inventory</span>
<p class="text-xs text-base-content/60 mt-0.5">Create, edit, and archive products and varieties</p>
</div>
</label>
<label class="label cursor-pointer justify-start gap-3 p-3 bg-base-100 border border-base-300 rounded-lg">
<input type="checkbox" name="permissions[]" value="edit_prices" class="checkbox checkbox-sm" />
<div class="flex-1">
<span class="label-text font-medium">Edit prices</span>
<p class="text-xs text-base-content/60 mt-0.5">Manipulate product pricing and apply blanket discounts</p>
</div>
</label>
<label class="label cursor-pointer justify-start gap-3 p-3 bg-base-100 border border-base-300 rounded-lg">
<input type="checkbox" name="permissions[]" value="manage_orders_received" class="checkbox checkbox-sm" />
<div class="flex-1">
<span class="label-text font-medium">Manage Orders Received</span>
<p class="text-xs text-base-content/60 mt-0.5">Update order statuses, create manual orders</p>
</div>
</label>
<label class="label cursor-pointer justify-start gap-3 p-3 bg-base-100 border border-base-300 rounded-lg">
<input type="checkbox" name="permissions[]" value="manage_billing" class="checkbox checkbox-sm" />
<div class="flex-1">
<span class="label-text font-medium">Manage billing</span>
<p class="text-xs text-base-content/60 mt-0.5">Manage billing information for platform fees (Admin only)</p>
</div>
</label>
</div>
</div>
<hr class="border-base-300 my-4" />
<!-- Customer Management -->
<div class="mb-6">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<span class="icon-[heroicons--users] size-5"></span>
<h5 class="font-semibold">Customer Management</h5>
</div>
<label class="label cursor-pointer gap-2 p-0">
<span class="label-text text-sm">Enable All</span>
<input type="checkbox" class="toggle toggle-sm toggle-primary" />
</label>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 pl-7">
<label class="label cursor-pointer justify-start gap-3 p-3 bg-base-100 border border-base-300 rounded-lg">
<input type="checkbox" name="permissions[]" value="manage_customers" class="checkbox checkbox-sm" />
<div class="flex-1">
<span class="label-text font-medium">Manage Customers and Contacts</span>
<p class="text-xs text-base-content/60 mt-0.5">Manage customer records, apply discounts and shipping charges</p>
</div>
</label>
<label class="label cursor-pointer justify-start gap-3 p-3 bg-base-100 border border-base-300 rounded-lg">
<input type="checkbox" name="permissions[]" value="access_sales_reports" class="checkbox checkbox-sm" />
<div class="flex-1">
<span class="label-text font-medium">Access sales reports</span>
<p class="text-xs text-base-content/60 mt-0.5">Access and download all sales reports and dashboards</p>
</div>
</label>
<label class="label cursor-pointer justify-start gap-3 p-3 bg-base-100 border border-base-300 rounded-lg">
<input type="checkbox" name="permissions[]" value="export_crm" class="checkbox checkbox-sm" />
<div class="flex-1">
<span class="label-text font-medium">Export CRM</span>
<p class="text-xs text-base-content/60 mt-0.5">Export customers/contacts as a CSV file</p>
</div>
</label>
</div>
</div>
<hr class="border-base-300 my-4" />
<!-- Logistics -->
<div class="mb-6">
<div class="flex items-center gap-2 mb-3">
<span class="icon-[heroicons--truck] size-5"></span>
<h5 class="font-semibold">Logistics</h5>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 pl-7">
<label class="label cursor-pointer justify-start gap-3 p-3 bg-base-100 border border-base-300 rounded-lg">
<input type="checkbox" name="permissions[]" value="manage_fulfillment" class="checkbox checkbox-sm" />
<div class="flex-1">
<span class="label-text font-medium">Manage fulfillment</span>
<p class="text-xs text-base-content/60 mt-0.5">Access to Fulfillment & Shipment pages and update statuses</p>
</div>
</label>
</div>
</div>
<hr class="border-base-300 my-4" />
<!-- Email -->
<div class="mb-6">
<div class="flex items-center gap-2 mb-3">
<span class="icon-[heroicons--envelope] size-5"></span>
<h5 class="font-semibold">Email</h5>
</div>
<div class="grid grid-cols-1 gap-4 pl-7">
<label class="label cursor-pointer justify-start gap-3 p-3 bg-base-100 border border-base-300 rounded-lg">
<input type="checkbox" name="permissions[]" value="receive_order_emails" class="checkbox checkbox-sm" />
<div class="flex-1">
<span class="label-text font-medium">Receive New & Accepted order emails</span>
<p class="text-xs text-base-content/60 mt-0.5">Checking this box enables user to receive New & Accepted order emails for all customers</p>
</div>
</label>
<div class="alert bg-base-200 border-base-300">
<span class="icon-[heroicons--information-circle] size-5"></span>
<div class="text-sm">
By default, all users receive emails for customers in which they are the assigned sales rep
</div>
</div>
</div>
</div>
<hr class="border-base-300 my-4" />
<!-- Data Control -->
<div class="mb-6">
<div class="flex items-center gap-2 mb-3">
<span class="icon-[heroicons--lock-closed] size-5"></span>
<h5 class="font-semibold">Data Control</h5>
</div>
<div class="grid grid-cols-1 gap-4 pl-7">
<label class="label cursor-pointer justify-start gap-3 p-3 bg-base-100 border border-base-300 rounded-lg">
<input type="checkbox" name="permissions[]" value="limit_to_assigned_customers" class="checkbox checkbox-sm" />
<div class="flex-1">
<span class="label-text font-medium">Limit access to assigned customers</span>
<p class="text-xs text-base-content/60 mt-0.5">When enabled, this user can only view/manage customers, contacts, and orders assigned to them</p>
</div>
</label>
</div>
</div>
<hr class="border-base-300 my-4" />
<!-- Other Settings -->
<div class="mb-6">
<div class="flex items-center gap-2 mb-3">
<span class="icon-[heroicons--cog-6-tooth] size-5"></span>
<h5 class="font-semibold">Other Settings</h5>
</div>
<div class="grid grid-cols-1 gap-4 pl-7">
<label class="label cursor-pointer justify-start gap-3 p-3 bg-base-100 border border-base-300 rounded-lg">
<input type="checkbox" name="permissions[]" value="access_developer_options" class="checkbox checkbox-sm" />
<div class="flex-1">
<span class="label-text font-medium">Access Developer Options</span>
<p class="text-xs text-base-content/60 mt-0.5">Create and manage Webhooks and API Keys</p>
</div>
</label>
</div>
</div>
</div>
<hr class="border-base-300 my-6" />
<!-- Danger Zone -->
<div class="mb-6">
<h4 class="font-semibold mb-4 text-base text-error">Danger Zone</h4>
<button type="button" class="btn btn-outline btn-error gap-2">
<span class="icon-[heroicons--user-minus] size-4"></span>
Deactivate User
</button>
</div>
</div>
<div class="flex-shrink-0 border-t border-base-300 p-6 pt-4">
<div class="flex gap-3 justify-end">
<button type="button" onclick="edit_user_modal_{{ $user->id }}.close()" class="btn btn-ghost">Cancel</button>
<button type="submit" class="btn btn-primary gap-2">
<span class="icon-[heroicons--arrow-down-tray] size-4"></span>
Save Changes
</button>
</div>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<script>
function openEditModal{{ $user->id }}() {
document.getElementById('edit_user_modal_{{ $user->id }}').showModal();
}
</script>
@endforeach
<!-- User Login History Audit Table -->
<div class="card bg-base-100 border border-base-300 mt-8">
<div class="card-body">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-lg font-semibold flex items-center gap-2">
<span class="icon-[heroicons--shield-check] size-5 text-primary"></span>
User Login History
</h2>
<p class="text-sm text-base-content/60 mt-1">Audit log of user authentication activity</p>
</div>
</div>
@php
// TODO: Replace with actual login history data from controller
// This requires a login_history table or audit_logs table
// Sample data for development/testing
$loginHistory = collect([
(object) [
'user' => (object) ['name' => 'John Smith', 'email' => 'john@cannabrands.biz'],
'created_at' => now()->subHours(2),
'ip_address' => '192.168.1.100',
'user_agent_parsed' => 'Chrome 120 on macOS',
'location' => 'Phoenix, AZ',
'success' => true,
],
(object) [
'user' => (object) ['name' => 'Sarah Johnson', 'email' => 'sarah@cannabrands.biz'],
'created_at' => now()->subHours(5),
'ip_address' => '192.168.1.101',
'user_agent_parsed' => 'Firefox 121 on Windows 11',
'location' => 'Scottsdale, AZ',
'success' => true,
],
(object) [
'user' => (object) ['name' => 'Mike Davis', 'email' => 'mike@cannabrands.biz'],
'created_at' => now()->subDay(),
'ip_address' => '192.168.1.102',
'user_agent_parsed' => 'Safari 17 on iPhone',
'location' => 'Tempe, AZ',
'success' => true,
],
(object) [
'user' => (object) ['name' => 'Unknown User', 'email' => 'test@example.com'],
'created_at' => now()->subDay()->subHours(3),
'ip_address' => '203.0.113.42',
'user_agent_parsed' => 'Chrome 120 on Windows 10',
'location' => 'Unknown',
'success' => false,
],
(object) [
'user' => (object) ['name' => 'Emily Rodriguez', 'email' => 'emily@cannabrands.biz'],
'created_at' => now()->subDays(2),
'ip_address' => '192.168.1.103',
'user_agent_parsed' => 'Edge 120 on Windows 11',
'location' => 'Mesa, AZ',
'success' => true,
],
]);
@endphp
@if($loginHistory->isNotEmpty())
<div class="overflow-x-auto">
<table class="table table-sm">
<thead class="bg-base-200">
<tr>
<th>User</th>
<th>Date & Time</th>
<th>IP Address</th>
<th>Device / Browser</th>
<th>Location</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@foreach($loginHistory as $log)
<tr class="hover:bg-base-200/50">
<td>
<div class="font-medium">{{ $log->user->name }}</div>
<div class="text-xs text-base-content/60">{{ $log->user->email }}</div>
</td>
<td>
<div class="text-sm">{{ $log->created_at->format('M d, Y') }}</div>
<div class="text-xs text-base-content/60">{{ $log->created_at->format('g:i A') }}</div>
</td>
<td class="font-mono text-xs">{{ $log->ip_address }}</td>
<td>
<div class="text-sm">{{ $log->user_agent_parsed ?? 'Unknown' }}</div>
</td>
<td class="text-sm">{{ $log->location ?? '—' }}</td>
<td>
@if($log->success)
<div class="badge badge-success badge-sm gap-1">
<span class="icon-[heroicons--check] size-3"></span>
Success
</div>
@else
<div class="badge badge-error badge-sm gap-1">
<span class="icon-[heroicons--x-mark] size-3"></span>
Failed
</div>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div class="text-center py-12 text-base-content/60">
<span class="icon-[heroicons--shield-check] size-12 mx-auto mb-3 opacity-30"></span>
<p class="text-sm font-medium">No login history available</p>
<p class="text-xs mt-1">User authentication logs will appear here once the audit system is configured.</p>
</div>
@endif
</div>
</div>
@endsection

View File

@@ -28,7 +28,7 @@ export default defineConfig(({ mode }) => {
],
server: {
host: '0.0.0.0',
port: 5173,
port: parseInt(env.VITE_PORT || '5173'),
strictPort: true,
origin: viteOrigin,
cors: {