Compare commits
16 Commits
fix/asset-
...
feat/cicd-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80b58d5973 | ||
|
|
93648ed001 | ||
|
|
52b6bae17e | ||
|
|
88b201222f | ||
|
|
de402c03d5 | ||
|
|
b92ba4b86d | ||
|
|
f8f219f00b | ||
|
|
f16dac012d | ||
|
|
f566b83cc6 | ||
|
|
418da7a39e | ||
|
|
3c6fe92811 | ||
|
|
7d3243b67e | ||
|
|
8f6597f428 | ||
|
|
64d38b8b2f | ||
|
|
7aa366eda9 | ||
|
|
d7adaf0cba |
@@ -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
|
||||
|
||||
@@ -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"*
|
||||
131
.woodpecker/GITEA_BRANCH_PROTECTION.md
Normal file
131
.woodpecker/GITEA_BRANCH_PROTECTION.md
Normal 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.
|
||||
@@ -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."*
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -54,6 +54,8 @@ return [
|
||||
|
||||
'url' => env('APP_URL', 'http://localhost'),
|
||||
|
||||
'asset_url' => env('ASSET_URL'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Timezone
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
168
database/seeders/DevCleanupSeeder.php
Normal file
168
database/seeders/DevCleanupSeeder.php
Normal 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
164
database/seeders/DevMediaSyncSeeder.php
Normal file
164
database/seeders/DevMediaSyncSeeder.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
68
docker/ci/Dockerfile
Normal 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!"
|
||||
549
docs/SUITES_AND_PRICING_MODEL.md
Normal file
549
docs/SUITES_AND_PRICING_MODEL.md
Normal 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
162
docs/USER_MANAGEMENT.md
Normal 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.
|
||||
@@ -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
@@ -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) │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user