Compare commits
113 Commits
feat/omnic
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f652ccba90 | ||
|
|
782e6797d8 | ||
|
|
09ff7b27a5 | ||
|
|
fe994205f2 | ||
|
|
cf80a8b681 | ||
|
|
5bdd6a2225 | ||
|
|
93678e59bc | ||
|
|
14c30dad03 | ||
|
|
08d49b9b67 | ||
|
|
036ae5c6f6 | ||
|
|
4c45805390 | ||
|
|
fc943afb36 | ||
|
|
25ae50dcd6 | ||
|
|
d1422efe87 | ||
|
|
45da5d075d | ||
|
|
bdc54da4ad | ||
|
|
cb6dc5e433 | ||
|
|
3add610e85 | ||
|
|
183a22c475 | ||
|
|
f2297d62f2 | ||
|
|
6b994147c3 | ||
|
|
a6d9e203c2 | ||
|
|
f652c19b24 | ||
|
|
76ce86fb41 | ||
|
|
5a22f7dbb6 | ||
|
|
5b8809b962 | ||
|
|
cdf982ed39 | ||
|
|
aac83a084c | ||
|
|
5f9613290d | ||
|
|
c92cd230d5 | ||
|
|
bcbfdd3c91 | ||
|
|
3c21093e66 | ||
|
|
e4e0a19873 | ||
|
|
5b0503abf5 | ||
|
|
cc8aab7ee1 | ||
|
|
2c1f7d093f | ||
|
|
11a07692ad | ||
|
|
05754c9d5b | ||
|
|
e26da88f22 | ||
|
|
b703b27676 | ||
|
|
5dffd96187 | ||
|
|
9ff1f2b37b | ||
|
|
23ad9f2824 | ||
|
|
b90cb829c9 | ||
|
|
69d9174314 | ||
|
|
c350ecbb3c | ||
|
|
06098c7013 | ||
|
|
709321383c | ||
|
|
7506466c38 | ||
|
|
c84455a11b | ||
|
|
2e7fff135c | ||
|
|
a1a8e3ee9c | ||
|
|
72ab5d8baa | ||
|
|
fc715c6022 | ||
|
|
32a00493f8 | ||
|
|
ffc9405c34 | ||
|
|
732c46cabb | ||
|
|
1906a28347 | ||
|
|
5de445d1cf | ||
|
|
9855d41869 | ||
|
|
319c5cc4d5 | ||
|
|
44af326ebb | ||
|
|
4f4e96dd84 | ||
|
|
fcc428b9f1 | ||
|
|
fc9580470e | ||
|
|
bc9aaf745d | ||
|
|
9df385e2e1 | ||
|
|
102b2c1803 | ||
|
|
6705a8916a | ||
|
|
3e71c35c9e | ||
|
|
c772431475 | ||
|
|
c6b8d76373 | ||
|
|
6ce095c60a | ||
|
|
45ed3818fa | ||
|
|
461b37ab30 | ||
|
|
88167995b1 | ||
|
|
e8622765a2 | ||
|
|
983752a396 | ||
|
|
b338571fb9 | ||
|
|
7990c2ab55 | ||
|
|
e419c57072 | ||
|
|
05144fe04b | ||
|
|
23aa144f85 | ||
|
|
bdf56d3d95 | ||
|
|
de1574f920 | ||
|
|
ff2f9ba64c | ||
|
|
362cb8091b | ||
|
|
0af81efac0 | ||
|
|
c705ef0cd0 | ||
|
|
ae5d7bf47a | ||
|
|
76ced26127 | ||
|
|
e98ad5d611 | ||
|
|
37a6eb67b2 | ||
|
|
2bf97fe4e1 | ||
|
|
f9130d1c67 | ||
|
|
a542112361 | ||
|
|
b2108651d8 | ||
|
|
b81b3ea8eb | ||
|
|
3ae38ff5ee | ||
|
|
5218a44701 | ||
|
|
6234cea62c | ||
|
|
6e26a65f12 | ||
|
|
86532e27fe | ||
|
|
f2b8d04d03 | ||
|
|
615dbd3ee6 | ||
|
|
fb6cf407d4 | ||
|
|
9fcfc8b484 | ||
|
|
829d4c6b6c | ||
|
|
60e1564c0f | ||
|
|
a42d1bc3c8 | ||
|
|
729234bd7f | ||
|
|
d718741cd3 | ||
|
|
db2386b8a9 |
@@ -38,13 +38,17 @@ steps:
|
||||
- |
|
||||
cat > .env << 'EOF'
|
||||
APP_NAME="Cannabrands Hub"
|
||||
APP_ENV=production
|
||||
APP_ENV=development
|
||||
APP_KEY=base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
|
||||
EOF
|
||||
# Restore composer cache if available
|
||||
- mkdir -p /root/.composer/cache
|
||||
- if [ -d .composer-cache ]; then cp -r .composer-cache/* /root/.composer/cache/ 2>/dev/null || true; fi
|
||||
- composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader --no-progress
|
||||
# Clean vendor and bootstrap cache to force fresh install
|
||||
- rm -rf vendor bootstrap/cache/*.php
|
||||
- composer install --no-interaction --prefer-dist --optimize-autoloader --no-progress
|
||||
# Verify test command is available
|
||||
- php artisan list test | head -5
|
||||
# Save cache for next build
|
||||
- mkdir -p .composer-cache && cp -r /root/.composer/cache/* .composer-cache/ 2>/dev/null || true
|
||||
- echo "✅ Composer done"
|
||||
@@ -85,28 +89,8 @@ steps:
|
||||
when:
|
||||
event: pull_request
|
||||
|
||||
# Split tests: Unit tests (fast, no DB)
|
||||
# Split tests: Unit tests (with DB - some unit tests use factories)
|
||||
tests-unit:
|
||||
image: 10.100.9.70:5000/kirschbaumdevelopment/laravel-test-runner:8.3
|
||||
depends_on:
|
||||
- composer-install
|
||||
when:
|
||||
event: pull_request
|
||||
environment:
|
||||
APP_ENV: testing
|
||||
CACHE_STORE: array
|
||||
SESSION_DRIVER: array
|
||||
QUEUE_CONNECTION: sync
|
||||
DB_CONNECTION: sqlite
|
||||
DB_DATABASE: ":memory:"
|
||||
commands:
|
||||
- cp .env.example .env
|
||||
- php artisan key:generate
|
||||
- php artisan test --testsuite=Unit --parallel
|
||||
- echo "✅ Unit tests passed"
|
||||
|
||||
# Split tests: Feature tests (with DB)
|
||||
tests-feature:
|
||||
image: 10.100.9.70:5000/kirschbaumdevelopment/laravel-test-runner:8.3
|
||||
depends_on:
|
||||
- composer-install
|
||||
@@ -123,13 +107,37 @@ steps:
|
||||
DB_DATABASE: cannabrands_test
|
||||
DB_USERNAME: cannabrands
|
||||
DB_PASSWORD: SpDyCannaBrands2024
|
||||
commands:
|
||||
- cp .env.example .env
|
||||
- php artisan key:generate
|
||||
- php artisan test --testsuite=Unit
|
||||
- echo "✅ Unit tests passed"
|
||||
|
||||
# Split tests: Feature tests (with DB)
|
||||
tests-feature:
|
||||
image: 10.100.9.70:5000/kirschbaumdevelopment/laravel-test-runner:8.3
|
||||
depends_on:
|
||||
- composer-install
|
||||
when:
|
||||
event: pull_request
|
||||
environment:
|
||||
APP_ENV: testing
|
||||
CACHE_STORE: array
|
||||
SESSION_DRIVER: array
|
||||
QUEUE_CONNECTION: sync
|
||||
DB_CONNECTION: pgsql
|
||||
DB_HOST: 10.100.7.50
|
||||
DB_PORT: 5432
|
||||
DB_DATABASE: cannabrands_test
|
||||
DB_USERNAME: cannabrands
|
||||
DB_PASSWORD: SpDyCannaBrands2024
|
||||
REDIS_HOST: 10.100.9.50
|
||||
REDIS_PORT: 6379
|
||||
REDIS_PASSWORD: SpDyR3d1s2024!
|
||||
commands:
|
||||
- cp .env.example .env
|
||||
- php artisan key:generate
|
||||
- php artisan test --testsuite=Feature --parallel
|
||||
- php artisan test --testsuite=Feature
|
||||
- echo "✅ Feature tests passed"
|
||||
|
||||
# ============================================
|
||||
|
||||
@@ -69,14 +69,14 @@ git push origin develop
|
||||
|
||||
**Before (Mutable Tags - Problematic):**
|
||||
```
|
||||
code.cannabrands.app/cannabrands/hub:dev # Overwritten each build
|
||||
git.spdy.io/cannabrands/hub:dev # Overwritten each build
|
||||
```
|
||||
|
||||
**After (Immutable Tags - Best Practice):**
|
||||
```
|
||||
code.cannabrands.app/cannabrands/hub:dev-a28d5b5 # Unique SHA tag
|
||||
code.cannabrands.app/cannabrands/hub:dev # Latest dev (convenience)
|
||||
code.cannabrands.app/cannabrands/hub:sha-a28d5b5 # Generic SHA
|
||||
git.spdy.io/cannabrands/hub:dev-a28d5b5 # Unique SHA tag
|
||||
git.spdy.io/cannabrands/hub:dev # Latest dev (convenience)
|
||||
git.spdy.io/cannabrands/hub:sha-a28d5b5 # Generic SHA
|
||||
```
|
||||
|
||||
### Auto-Deploy Flow
|
||||
@@ -109,14 +109,14 @@ If a deployment breaks dev, roll back to the previous version:
|
||||
kubectl get deployment cannabrands-hub -n cannabrands-dev \
|
||||
-o jsonpath='{.spec.template.spec.containers[0].image}'
|
||||
|
||||
# Output: code.cannabrands.app/cannabrands/hub:dev-a28d5b5
|
||||
# Output: git.spdy.io/cannabrands/hub:dev-a28d5b5
|
||||
|
||||
# 2. Check git log for previous commit
|
||||
git log --oneline develop | head -5
|
||||
|
||||
# 3. Rollback to previous SHA
|
||||
kubectl set image deployment/cannabrands-hub \
|
||||
app=code.cannabrands.app/cannabrands/hub:dev-PREVIOUS_SHA \
|
||||
app=git.spdy.io/cannabrands/hub:dev-PREVIOUS_SHA \
|
||||
-n cannabrands-dev
|
||||
|
||||
# 4. Verify rollback
|
||||
@@ -156,7 +156,7 @@ deploy-staging:
|
||||
- chmod 600 ~/.kube/config
|
||||
- |
|
||||
kubectl set image deployment/cannabrands-hub \
|
||||
app=code.cannabrands.app/cannabrands/hub:staging-${CI_COMMIT_SHA:0:7} \
|
||||
app=git.spdy.io/cannabrands/hub:staging-${CI_COMMIT_SHA:0:7} \
|
||||
-n cannabrands-staging
|
||||
- kubectl rollout status deployment/cannabrands-hub -n cannabrands-staging --timeout=300s
|
||||
when:
|
||||
@@ -207,7 +207,7 @@ kubectl logs -n cannabrands-dev deployment/cannabrands-hub --tail=100
|
||||
cannabrands-hub-7d85986845-gnkbv 1/1 Running 0 45s
|
||||
|
||||
Image deployed:
|
||||
code.cannabrands.app/cannabrands/hub:dev-a28d5b5
|
||||
git.spdy.io/cannabrands/hub:dev-a28d5b5
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -47,8 +47,8 @@ steps:
|
||||
build-image:
|
||||
image: plugins/docker
|
||||
settings:
|
||||
registry: code.cannabrands.app
|
||||
repo: code.cannabrands.app/cannabrands/hub
|
||||
registry: git.spdy.io
|
||||
repo: git.spdy.io/cannabrands/hub
|
||||
tags: [latest, ${CI_COMMIT_SHA:0:8}]
|
||||
when:
|
||||
branch: master
|
||||
@@ -68,7 +68,7 @@ steps:
|
||||
```bash
|
||||
# On production server
|
||||
ssh cannabrands-prod
|
||||
docker pull code.cannabrands.app/cannabrands/hub:bef77df8
|
||||
docker pull git.spdy.io/cannabrands/hub:bef77df8
|
||||
docker-compose up -d
|
||||
# Or use deployment tool like Ansible, Deployer, etc.
|
||||
```
|
||||
@@ -108,7 +108,7 @@ steps:
|
||||
from_secret: ssh_private_key
|
||||
script:
|
||||
- cd /var/www/cannabrands
|
||||
- docker pull code.cannabrands.app/cannabrands/hub:${CI_COMMIT_SHA:0:8}
|
||||
- docker pull git.spdy.io/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
|
||||
@@ -160,7 +160,7 @@ steps:
|
||||
from_secret: ssh_private_key
|
||||
script:
|
||||
- cd /var/www/cannabrands
|
||||
- docker pull code.cannabrands.app/cannabrands/hub:${CI_COMMIT_SHA:0:8}
|
||||
- docker pull git.spdy.io/cannabrands/hub:${CI_COMMIT_SHA:0:8}
|
||||
- docker-compose up -d
|
||||
when:
|
||||
branch: develop
|
||||
@@ -176,7 +176,7 @@ steps:
|
||||
from_secret: ssh_private_key
|
||||
script:
|
||||
- cd /var/www/cannabrands
|
||||
- docker pull code.cannabrands.app/cannabrands/hub:${CI_COMMIT_SHA:0:8}
|
||||
- docker pull git.spdy.io/cannabrands/hub:${CI_COMMIT_SHA:0:8}
|
||||
- docker-compose up -d
|
||||
when:
|
||||
branch: master
|
||||
@@ -367,7 +367,7 @@ Production:
|
||||
```bash
|
||||
# Quick rollback (under 2 minutes)
|
||||
ssh cannabrands-prod
|
||||
docker pull code.cannabrands.app/cannabrands/hub:PREVIOUS_COMMIT_SHA
|
||||
docker pull git.spdy.io/cannabrands/hub:PREVIOUS_COMMIT_SHA
|
||||
docker-compose up -d
|
||||
|
||||
# Database rollback (if migrations ran)
|
||||
@@ -536,8 +536,8 @@ steps:
|
||||
build-image:
|
||||
image: plugins/docker
|
||||
settings:
|
||||
registry: code.cannabrands.app
|
||||
repo: code.cannabrands.app/cannabrands/hub
|
||||
registry: git.spdy.io
|
||||
repo: git.spdy.io/cannabrands/hub
|
||||
tags:
|
||||
- ${CI_COMMIT_BRANCH}
|
||||
- ${CI_COMMIT_SHA:0:8}
|
||||
@@ -559,7 +559,7 @@ steps:
|
||||
from_secret: staging_ssh_key
|
||||
script:
|
||||
- cd /var/www/cannabrands
|
||||
- docker pull code.cannabrands.app/cannabrands/hub:${CI_COMMIT_SHA:0:8}
|
||||
- docker pull git.spdy.io/cannabrands/hub:${CI_COMMIT_SHA:0:8}
|
||||
- docker-compose up -d
|
||||
- docker exec cannabrands php artisan migrate --force
|
||||
- docker exec cannabrands php artisan config:cache
|
||||
@@ -582,7 +582,7 @@ steps:
|
||||
- echo "To deploy to production:"
|
||||
- echo " ssh cannabrands-prod"
|
||||
- echo " cd /var/www/cannabrands"
|
||||
- echo " docker pull code.cannabrands.app/cannabrands/hub:${CI_COMMIT_SHA:0:8}"
|
||||
- echo " docker pull git.spdy.io/cannabrands/hub:${CI_COMMIT_SHA:0:8}"
|
||||
- echo " docker-compose up -d"
|
||||
- echo ""
|
||||
- echo "⚠️ Remember: Check deployment checklist first!"
|
||||
|
||||
@@ -102,7 +102,7 @@ Push to master → Woodpecker runs:
|
||||
→ Build Docker image
|
||||
→ Tag: cannabrands-hub:c165bf9 (commit SHA)
|
||||
→ Tag: cannabrands-hub:latest
|
||||
→ Push to code.cannabrands.app/cannabrands/hub
|
||||
→ Push to git.spdy.io/cannabrands/hub
|
||||
→ Image ready, no deployment yet
|
||||
```
|
||||
|
||||
@@ -177,7 +177,7 @@ CMD ["php-fpm"]
|
||||
### Staging Deployment:
|
||||
```bash
|
||||
# Pull the same image
|
||||
docker pull code.cannabrands.app/cannabrands/hub:c165bf9
|
||||
docker pull git.spdy.io/cannabrands/hub:c165bf9
|
||||
|
||||
# Run with staging environment
|
||||
docker run \
|
||||
@@ -186,13 +186,13 @@ docker run \
|
||||
-e DB_DATABASE=cannabrands_staging \
|
||||
-e APP_DEBUG=true \
|
||||
-e MAIL_MAILER=log \
|
||||
code.cannabrands.app/cannabrands/hub:c165bf9
|
||||
git.spdy.io/cannabrands/hub:c165bf9
|
||||
```
|
||||
|
||||
### Production Deployment:
|
||||
```bash
|
||||
# Pull THE EXACT SAME IMAGE
|
||||
docker pull code.cannabrands.app/cannabrands/hub:c165bf9
|
||||
docker pull git.spdy.io/cannabrands/hub:c165bf9
|
||||
|
||||
# Run with production environment
|
||||
docker run \
|
||||
@@ -201,7 +201,7 @@ docker run \
|
||||
-e DB_DATABASE=cannabrands_production \
|
||||
-e APP_DEBUG=false \
|
||||
-e MAIL_MAILER=smtp \
|
||||
code.cannabrands.app/cannabrands/hub:c165bf9
|
||||
git.spdy.io/cannabrands/hub:c165bf9
|
||||
```
|
||||
|
||||
**Key point**: Notice it's the **exact same image** (`c165bf9`), only environment variables differ.
|
||||
@@ -218,7 +218,7 @@ docker run \
|
||||
version: '3.8'
|
||||
services:
|
||||
app:
|
||||
image: code.cannabrands.app/cannabrands/hub:latest
|
||||
image: git.spdy.io/cannabrands/hub:latest
|
||||
env_file:
|
||||
- .env.staging # Staging-specific vars
|
||||
ports:
|
||||
@@ -253,7 +253,7 @@ secrets:
|
||||
version: '3.8'
|
||||
services:
|
||||
app:
|
||||
image: code.cannabrands.app/cannabrands/hub:c165bf9 # Specific SHA
|
||||
image: git.spdy.io/cannabrands/hub:c165bf9 # Specific SHA
|
||||
env_file:
|
||||
- .env.production # Production-specific vars
|
||||
ports:
|
||||
@@ -301,7 +301,7 @@ spec:
|
||||
spec:
|
||||
containers:
|
||||
- name: app
|
||||
image: code.cannabrands.app/cannabrands/hub:c165bf9
|
||||
image: git.spdy.io/cannabrands/hub:c165bf9
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: app-config-staging # Different per namespace
|
||||
@@ -350,8 +350,8 @@ steps:
|
||||
build-and-publish:
|
||||
image: plugins/docker
|
||||
settings:
|
||||
registry: code.cannabrands.app
|
||||
repo: code.cannabrands.app/cannabrands/hub
|
||||
registry: git.spdy.io
|
||||
repo: git.spdy.io/cannabrands/hub
|
||||
tags:
|
||||
- latest # Always overwrite
|
||||
- ${CI_COMMIT_SHA:0:8} # Immutable SHA
|
||||
@@ -384,7 +384,7 @@ Date: 2025-01-15 14:30:00 PST
|
||||
Image: cannabrands-hub:c165bf9
|
||||
Deployed by: jon@cannabrands.com
|
||||
Approved by: compliance@cannabrands.com
|
||||
Git commit: https://code.cannabrands.app/.../c165bf9
|
||||
Git commit: https://git.spdy.io/.../c165bf9
|
||||
Changes: Invoice picking workflow update
|
||||
Tests passed: ✅ 28/28
|
||||
Staging tested: ✅ 2 hours
|
||||
@@ -424,7 +424,7 @@ Rollback image: cannabrands-hub:a1b2c3d
|
||||
```bash
|
||||
# On production server
|
||||
ssh cannabrands-prod
|
||||
docker pull code.cannabrands.app/cannabrands/hub:c165bf9
|
||||
docker pull git.spdy.io/cannabrands/hub:c165bf9
|
||||
docker-compose -f docker-compose.production.yml up -d
|
||||
```
|
||||
|
||||
@@ -487,14 +487,14 @@ steps:
|
||||
security-scan:
|
||||
image: aquasec/trivy
|
||||
commands:
|
||||
- trivy image code.cannabrands.app/cannabrands/hub:${CI_COMMIT_SHA:0:8}
|
||||
- trivy image git.spdy.io/cannabrands/hub:${CI_COMMIT_SHA:0:8}
|
||||
```
|
||||
|
||||
### 4. Sign Images (Advanced)
|
||||
|
||||
Use Cosign to cryptographically sign images:
|
||||
```bash
|
||||
cosign sign code.cannabrands.app/cannabrands/hub:c165bf9
|
||||
cosign sign git.spdy.io/cannabrands/hub:c165bf9
|
||||
```
|
||||
|
||||
Compliance benefit: Prove image hasn't been tampered with.
|
||||
@@ -507,10 +507,10 @@ Compliance benefit: Prove image hasn't been tampered with.
|
||||
|
||||
```bash
|
||||
# List recent deployments
|
||||
docker images code.cannabrands.app/cannabrands/hub
|
||||
docker images git.spdy.io/cannabrands/hub
|
||||
|
||||
# Rollback to previous version
|
||||
docker pull code.cannabrands.app/cannabrands/hub:a1b2c3d
|
||||
docker pull git.spdy.io/cannabrands/hub:a1b2c3d
|
||||
docker-compose -f docker-compose.production.yml up -d
|
||||
```
|
||||
|
||||
@@ -531,7 +531,7 @@ deploy:
|
||||
# Before risky deployment
|
||||
git tag -a v1.5.2-stable -m "Last known good version"
|
||||
docker tag cannabrands-hub:current cannabrands-hub:v1.5.2-stable
|
||||
docker push code.cannabrands.app/cannabrands/hub:v1.5.2-stable
|
||||
docker push git.spdy.io/cannabrands/hub:v1.5.2-stable
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -254,25 +254,25 @@ WORKDIR /woodpecker/src
|
||||
|
||||
**Build and push to Gitea:**
|
||||
```bash
|
||||
docker build -f docker/ci-php.Dockerfile -t code.cannabrands.app/cannabrands/ci-php:8.3 .
|
||||
docker push code.cannabrands.app/cannabrands/ci-php:8.3
|
||||
docker build -f docker/ci-php.Dockerfile -t git.spdy.io/cannabrands/ci-php:8.3 .
|
||||
docker push git.spdy.io/cannabrands/ci-php:8.3
|
||||
```
|
||||
|
||||
**Update `.woodpecker/.ci.yml`:**
|
||||
```yaml
|
||||
steps:
|
||||
php-lint:
|
||||
image: code.cannabrands.app/cannabrands/ci-php:8.3
|
||||
image: git.spdy.io/cannabrands/ci-php:8.3
|
||||
commands:
|
||||
- find app routes database -name "*.php" -exec php -l {} \;
|
||||
|
||||
composer-install:
|
||||
image: code.cannabrands.app/cannabrands/ci-php:8.3
|
||||
image: git.spdy.io/cannabrands/ci-php:8.3
|
||||
commands:
|
||||
- composer install --no-interaction --prefer-dist --optimize-autoloader
|
||||
|
||||
code-style:
|
||||
image: code.cannabrands.app/cannabrands/ci-php:8.3
|
||||
image: git.spdy.io/cannabrands/ci-php:8.3
|
||||
commands:
|
||||
- ./vendor/bin/pint --test
|
||||
```
|
||||
|
||||
@@ -107,7 +107,7 @@ version: '3.8'
|
||||
|
||||
services:
|
||||
app:
|
||||
image: code.cannabrands.app/cannabrands/hub:latest
|
||||
image: git.spdy.io/cannabrands/hub:latest
|
||||
container_name: cannabrands_app
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -204,8 +204,8 @@ steps:
|
||||
build-image:
|
||||
image: woodpeckerci/plugin-docker-buildx
|
||||
settings:
|
||||
registry: code.cannabrands.app
|
||||
repo: code.cannabrands.app/cannabrands/hub
|
||||
registry: git.spdy.io
|
||||
repo: git.spdy.io/cannabrands/hub
|
||||
username:
|
||||
from_secret: gitea_username
|
||||
password:
|
||||
@@ -564,7 +564,7 @@ docker images | grep cannabrands
|
||||
|
||||
```bash
|
||||
# Pull previous commit's image
|
||||
docker pull code.cannabrands.app/cannabrands/hub:PREVIOUS_SHA
|
||||
docker pull git.spdy.io/cannabrands/hub:PREVIOUS_SHA
|
||||
|
||||
# Update docker-compose.yml to use specific tag
|
||||
docker compose up -d app
|
||||
|
||||
@@ -11,10 +11,10 @@ Once you implement production deployments, Woodpecker will:
|
||||
|
||||
Your images will be available at:
|
||||
```
|
||||
code.cannabrands.app/cannabrands/hub
|
||||
git.spdy.io/cannabrands/hub
|
||||
```
|
||||
|
||||
**View packages**: https://code.cannabrands.app/Cannabrands/hub/-/packages
|
||||
**View packages**: https://git.spdy.io/Cannabrands/hub/-/packages
|
||||
|
||||
## Step 1: Enable Gitea Package Registry
|
||||
|
||||
@@ -22,7 +22,7 @@ First, verify the registry is enabled on your Gitea instance:
|
||||
|
||||
1. **Check as admin**: Admin → Site Administration → Configuration
|
||||
2. **Look for**: `[packages]` section with `ENABLED = true`
|
||||
3. **Test**: Visit https://code.cannabrands.app/-/packages
|
||||
3. **Test**: Visit https://git.spdy.io/-/packages
|
||||
|
||||
If not enabled, ask your Gitea admin to enable it in `app.ini`:
|
||||
```ini
|
||||
@@ -61,8 +61,8 @@ steps:
|
||||
build-and-publish:
|
||||
image: plugins/docker
|
||||
settings:
|
||||
registry: code.cannabrands.app
|
||||
repo: code.cannabrands.app/cannabrands/hub
|
||||
registry: git.spdy.io
|
||||
repo: git.spdy.io/cannabrands/hub
|
||||
tags:
|
||||
- latest
|
||||
- ${CI_COMMIT_SHA:0:8}
|
||||
@@ -136,15 +136,15 @@ Once images are published, you can pull them on your production servers:
|
||||
|
||||
```bash
|
||||
# Login to Gitea registry
|
||||
docker login code.cannabrands.app
|
||||
docker login git.spdy.io
|
||||
# Username: your-gitea-username
|
||||
# Password: your-personal-access-token
|
||||
|
||||
# Pull latest image
|
||||
docker pull code.cannabrands.app/cannabrands/hub:latest
|
||||
docker pull git.spdy.io/cannabrands/hub:latest
|
||||
|
||||
# Or pull specific commit
|
||||
docker pull code.cannabrands.app/cannabrands/hub:bef77df8
|
||||
docker pull git.spdy.io/cannabrands/hub:bef77df8
|
||||
```
|
||||
|
||||
## Image Tagging Strategy
|
||||
@@ -218,8 +218,8 @@ steps:
|
||||
build-and-publish:
|
||||
image: plugins/docker
|
||||
settings:
|
||||
registry: code.cannabrands.app
|
||||
repo: code.cannabrands.app/cannabrands/hub
|
||||
registry: git.spdy.io
|
||||
repo: git.spdy.io/cannabrands/hub
|
||||
tags:
|
||||
- latest
|
||||
- ${CI_COMMIT_SHA:0:8}
|
||||
@@ -236,7 +236,7 @@ steps:
|
||||
notify-deploy:
|
||||
image: alpine:latest
|
||||
commands:
|
||||
- echo "✅ New image published: code.cannabrands.app/cannabrands/hub:${CI_COMMIT_SHA:0:8}"
|
||||
- echo "✅ New image published: git.spdy.io/cannabrands/hub:${CI_COMMIT_SHA:0:8}"
|
||||
- echo "Ready for deployment to production!"
|
||||
when:
|
||||
- branch: master
|
||||
@@ -271,8 +271,8 @@ services:
|
||||
- Subsequent builds will work fine
|
||||
|
||||
**Images not appearing in Gitea packages**
|
||||
- Check Gitea packages are enabled: https://code.cannabrands.app/-/packages
|
||||
- Verify registry URL is `code.cannabrands.app` (not `ci.cannabrands.app`)
|
||||
- Check Gitea packages are enabled: https://git.spdy.io/-/packages
|
||||
- Verify registry URL is `git.spdy.io` (not `ci.cannabrands.app`)
|
||||
|
||||
## Next Steps
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ git push origin 2025.11.3
|
||||
|
||||
### Step 3: Wait for CI Build (2-4 minutes)
|
||||
|
||||
Watch at: `code.cannabrands.app/cannabrands/hub/pipelines`
|
||||
Watch at: `git.spdy.io/cannabrands/hub/pipelines`
|
||||
|
||||
CI will automatically:
|
||||
- Run tests
|
||||
@@ -113,7 +113,7 @@ git push origin master
|
||||
```bash
|
||||
# Deploy specific version
|
||||
kubectl set image deployment/cannabrands \
|
||||
app=code.cannabrands.app/cannabrands/hub:2025.11.3
|
||||
app=git.spdy.io/cannabrands/hub:2025.11.3
|
||||
|
||||
# Watch deployment
|
||||
kubectl rollout status deployment/cannabrands
|
||||
@@ -131,7 +131,7 @@ kubectl get pods
|
||||
```bash
|
||||
# Option 1: Rollback to previous version
|
||||
kubectl set image deployment/cannabrands \
|
||||
app=code.cannabrands.app/cannabrands/hub:2025.11.2
|
||||
app=git.spdy.io/cannabrands/hub:2025.11.2
|
||||
|
||||
# Option 2: Kubernetes automatic rollback
|
||||
kubectl rollout undo deployment/cannabrands
|
||||
@@ -154,7 +154,7 @@ git push origin 2025.11.4
|
||||
|
||||
# 4. Deploy when confident
|
||||
kubectl set image deployment/cannabrands \
|
||||
app=code.cannabrands.app/cannabrands/hub:2025.11.4
|
||||
app=git.spdy.io/cannabrands/hub:2025.11.4
|
||||
```
|
||||
|
||||
---
|
||||
@@ -170,7 +170,7 @@ master → Branch tracking
|
||||
|
||||
**Use in K3s dev/staging:**
|
||||
```yaml
|
||||
image: code.cannabrands.app/cannabrands/hub:latest-dev
|
||||
image: git.spdy.io/cannabrands/hub:latest-dev
|
||||
imagePullPolicy: Always
|
||||
```
|
||||
|
||||
@@ -182,7 +182,7 @@ stable → Latest production release
|
||||
|
||||
**Use in K3s production:**
|
||||
```yaml
|
||||
image: code.cannabrands.app/cannabrands/hub:2025.11.3
|
||||
image: git.spdy.io/cannabrands/hub:2025.11.3
|
||||
imagePullPolicy: IfNotPresent
|
||||
```
|
||||
|
||||
@@ -214,7 +214,7 @@ docker build -t cannabrands:test .
|
||||
### View CI Status
|
||||
```bash
|
||||
# Visit Woodpecker
|
||||
open https://code.cannabrands.app/cannabrands/hub/pipelines
|
||||
open https://git.spdy.io/cannabrands/hub/pipelines
|
||||
|
||||
# Or check latest build
|
||||
# (Visit Gitea → Repository → Pipelines)
|
||||
@@ -227,7 +227,7 @@ open https://code.cannabrands.app/cannabrands/hub/pipelines
|
||||
### CI Build Failing
|
||||
```bash
|
||||
# Check Woodpecker logs
|
||||
# Visit: code.cannabrands.app/cannabrands/hub/pipelines
|
||||
# Visit: git.spdy.io/cannabrands/hub/pipelines
|
||||
|
||||
# Run tests locally first
|
||||
./vendor/bin/sail artisan test
|
||||
@@ -362,8 +362,8 @@ Before deploying:
|
||||
- Pair with senior dev for first release
|
||||
|
||||
### CI/CD
|
||||
- Woodpecker: `code.cannabrands.app/cannabrands/hub`
|
||||
- Gitea: `code.cannabrands.app/cannabrands/hub`
|
||||
- Woodpecker: `git.spdy.io/cannabrands/hub`
|
||||
- Gitea: `git.spdy.io/cannabrands/hub`
|
||||
- K3s Dashboard: (ask devops for link)
|
||||
|
||||
---
|
||||
@@ -371,13 +371,13 @@ Before deploying:
|
||||
## Important URLs
|
||||
|
||||
**Code Repository:**
|
||||
https://code.cannabrands.app/cannabrands/hub
|
||||
https://git.spdy.io/cannabrands/hub
|
||||
|
||||
**CI/CD Pipeline:**
|
||||
https://code.cannabrands.app/cannabrands/hub/pipelines
|
||||
https://git.spdy.io/cannabrands/hub/pipelines
|
||||
|
||||
**Container Registry:**
|
||||
https://code.cannabrands.app/-/packages/container/cannabrands%2Fhub
|
||||
https://git.spdy.io/-/packages/container/cannabrands%2Fhub
|
||||
|
||||
**Documentation:**
|
||||
`.woodpecker/` directory in repository
|
||||
@@ -430,7 +430,7 @@ Closes #42"
|
||||
| Deploy | `kubectl set image deployment/cannabrands app=...:2025.11.1` |
|
||||
| Rollback | `kubectl set image deployment/cannabrands app=...:2025.11.0` |
|
||||
| Check version | `kubectl get deployment cannabrands -o jsonpath='{.spec.template.spec.containers[0].image}'` |
|
||||
| View builds | Visit `code.cannabrands.app/cannabrands/hub/pipelines` |
|
||||
| View builds | Visit `git.spdy.io/cannabrands/hub/pipelines` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ git push origin master
|
||||
2. Tests run (PHP lint, Pint, PHPUnit)
|
||||
3. Docker image builds (if tests pass)
|
||||
4. Tagged as: latest-dev, dev-c658193, master
|
||||
5. Pushed to code.cannabrands.app/cannabrands/hub
|
||||
5. Pushed to git.spdy.io/cannabrands/hub
|
||||
6. Available in K3s dev namespace (manual or auto-pull)
|
||||
```
|
||||
|
||||
@@ -47,7 +47,7 @@ git push origin master
|
||||
**Use in K3s:**
|
||||
```yaml
|
||||
# dev/staging namespace
|
||||
image: code.cannabrands.app/cannabrands/hub:latest-dev
|
||||
image: git.spdy.io/cannabrands/hub:latest-dev
|
||||
imagePullPolicy: Always # Always pull newest
|
||||
```
|
||||
|
||||
@@ -81,7 +81,7 @@ git push origin 2025.11.1
|
||||
**Use in K3s:**
|
||||
```yaml
|
||||
# production namespace
|
||||
image: code.cannabrands.app/cannabrands/hub:2025.11.1
|
||||
image: git.spdy.io/cannabrands/hub:2025.11.1
|
||||
imagePullPolicy: IfNotPresent # Pin to specific version
|
||||
```
|
||||
|
||||
@@ -212,7 +212,7 @@ git push origin master
|
||||
./vendor/bin/sail artisan test
|
||||
|
||||
# Check CI is green
|
||||
# Visit: code.cannabrands.app/cannabrands/hub/pipelines
|
||||
# Visit: git.spdy.io/cannabrands/hub/pipelines
|
||||
|
||||
# Test in staging/dev environment
|
||||
# Verify key workflows work
|
||||
@@ -264,12 +264,12 @@ git push origin 2025.11.3
|
||||
|
||||
```bash
|
||||
# Watch Woodpecker build
|
||||
# Visit: code.cannabrands.app/cannabrands/hub/pipelines
|
||||
# Visit: git.spdy.io/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
|
||||
# - git.spdy.io/cannabrands/hub:2025.11.3
|
||||
# - git.spdy.io/cannabrands/hub:stable
|
||||
```
|
||||
|
||||
#### 5. Deploy to Production (When Ready)
|
||||
@@ -277,7 +277,7 @@ git push origin 2025.11.3
|
||||
```bash
|
||||
# Deploy new version
|
||||
kubectl set image deployment/cannabrands \
|
||||
app=code.cannabrands.app/cannabrands/hub:2025.11.3
|
||||
app=git.spdy.io/cannabrands/hub:2025.11.3
|
||||
|
||||
# Watch rollout
|
||||
kubectl rollout status deployment/cannabrands
|
||||
@@ -328,11 +328,11 @@ git push origin master
|
||||
```bash
|
||||
# Option 1: Rollback to specific version
|
||||
kubectl set image deployment/cannabrands \
|
||||
app=code.cannabrands.app/cannabrands/hub:2025.11.2
|
||||
app=git.spdy.io/cannabrands/hub:2025.11.2
|
||||
|
||||
# Option 2: Use previous stable
|
||||
kubectl set image deployment/cannabrands \
|
||||
app=code.cannabrands.app/cannabrands/hub:stable
|
||||
app=git.spdy.io/cannabrands/hub:stable
|
||||
|
||||
# Note: 'stable' is updated on every release
|
||||
# So if you just deployed 2025.11.3, 'stable' points to 2025.11.3
|
||||
@@ -367,7 +367,7 @@ git push origin 2025.11.4
|
||||
|
||||
# Deploy
|
||||
kubectl set image deployment/cannabrands \
|
||||
app=code.cannabrands.app/cannabrands/hub:2025.11.4
|
||||
app=git.spdy.io/cannabrands/hub:2025.11.4
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
**Current tagging strategy:**
|
||||
```
|
||||
code.cannabrands.app/cannabrands/hub:latest # Always changes
|
||||
code.cannabrands.app/cannabrands/hub:c658193 # Commit SHA (meaningless)
|
||||
code.cannabrands.app/cannabrands/hub:master # Branch name (changes)
|
||||
git.spdy.io/cannabrands/hub:latest # Always changes
|
||||
git.spdy.io/cannabrands/hub:c658193 # Commit SHA (meaningless)
|
||||
git.spdy.io/cannabrands/hub:master # Branch name (changes)
|
||||
```
|
||||
|
||||
**Issues:**
|
||||
@@ -143,8 +143,8 @@ The CI pipeline now builds images with version metadata for both development and
|
||||
build-image-dev:
|
||||
image: woodpeckerci/plugin-docker-buildx
|
||||
settings:
|
||||
registry: code.cannabrands.app
|
||||
repo: code.cannabrands.app/cannabrands/hub
|
||||
registry: git.spdy.io
|
||||
repo: git.spdy.io/cannabrands/hub
|
||||
tags:
|
||||
- dev # Latest dev build
|
||||
- sha-${CI_COMMIT_SHA:0:7} # Commit SHA
|
||||
@@ -170,13 +170,13 @@ build-image-release:
|
||||
**Result:**
|
||||
```
|
||||
# Development push to master
|
||||
code.cannabrands.app/cannabrands/hub:dev
|
||||
code.cannabrands.app/cannabrands/hub:sha-c658193
|
||||
code.cannabrands.app/cannabrands/hub:master
|
||||
git.spdy.io/cannabrands/hub:dev
|
||||
git.spdy.io/cannabrands/hub:sha-c658193
|
||||
git.spdy.io/cannabrands/hub:master
|
||||
|
||||
# Release (git tag 2025.10.1)
|
||||
code.cannabrands.app/cannabrands/hub:2025.10.1 # Specific version
|
||||
code.cannabrands.app/cannabrands/hub:latest # Latest stable
|
||||
git.spdy.io/cannabrands/hub:2025.10.1 # Specific version
|
||||
git.spdy.io/cannabrands/hub:latest # Latest stable
|
||||
```
|
||||
|
||||
---
|
||||
@@ -243,11 +243,11 @@ git checkout c658193
|
||||
```bash
|
||||
# Option 1: Rollback to specific version (recommended)
|
||||
kubectl set image deployment/cannabrands \
|
||||
app=code.cannabrands.app/cannabrands/hub:v1.2.2
|
||||
app=git.spdy.io/cannabrands/hub:v1.2.2
|
||||
|
||||
# Option 2: Rollback to last stable
|
||||
kubectl set image deployment/cannabrands \
|
||||
app=code.cannabrands.app/cannabrands/hub:stable
|
||||
app=git.spdy.io/cannabrands/hub:stable
|
||||
|
||||
# Option 3: Kubernetes rollback (uses previous deployment)
|
||||
kubectl rollout undo deployment/cannabrands
|
||||
@@ -281,7 +281,7 @@ cat CHANGELOG.md
|
||||
|
||||
# 5. Deploy specific version
|
||||
kubectl set image deployment/cannabrands \
|
||||
app=code.cannabrands.app/cannabrands/hub:v1.2.1
|
||||
app=git.spdy.io/cannabrands/hub:v1.2.1
|
||||
```
|
||||
|
||||
---
|
||||
@@ -357,7 +357,7 @@ audit-deployment:
|
||||
```
|
||||
Developer → Commit to master → CI tests → Build dev image
|
||||
↓
|
||||
code.cannabrands.app/cannabrands/hub:dev-COMMIT
|
||||
git.spdy.io/cannabrands/hub:dev-COMMIT
|
||||
↓
|
||||
Deploy to dev/staging (optional)
|
||||
```
|
||||
@@ -486,7 +486,7 @@ spec:
|
||||
spec:
|
||||
containers:
|
||||
- name: app
|
||||
image: code.cannabrands.app/cannabrands/hub:v1.2.3
|
||||
image: git.spdy.io/cannabrands/hub:v1.2.3
|
||||
imagePullPolicy: IfNotPresent # Don't pull if tag exists
|
||||
ports:
|
||||
- containerPort: 80
|
||||
@@ -535,7 +535,7 @@ git push origin master
|
||||
|
||||
# 5. Deploy to production (manual)
|
||||
kubectl set image deployment/cannabrands \
|
||||
app=code.cannabrands.app/cannabrands/hub:v1.3.0
|
||||
app=git.spdy.io/cannabrands/hub:v1.3.0
|
||||
```
|
||||
|
||||
### Emergency Rollback
|
||||
@@ -546,7 +546,7 @@ kubectl rollout undo deployment/cannabrands
|
||||
|
||||
# Or specific version
|
||||
kubectl set image deployment/cannabrands \
|
||||
app=code.cannabrands.app/cannabrands/hub:v1.2.3
|
||||
app=git.spdy.io/cannabrands/hub:v1.2.3
|
||||
|
||||
# Verify
|
||||
kubectl rollout status deployment/cannabrands
|
||||
|
||||
12
CLAUDE.md
12
CLAUDE.md
@@ -77,11 +77,13 @@ curl -X POST "https://git.spdy.io/api/v1/repos/Cannabrands/hub/pulls" \
|
||||
|---------|------|-------|
|
||||
| **Gitea** | `https://git.spdy.io` | Git repository |
|
||||
| **Woodpecker CI** | `https://ci.spdy.io` | CI/CD pipelines |
|
||||
| **Docker Registry** | `10.100.9.70:5000` | Local registry (insecure) |
|
||||
| **Docker Registry** | `registry.spdy.io` | HTTPS registry with Let's Encrypt |
|
||||
|
||||
**PostgreSQL (Dev)**
|
||||
**PostgreSQL (Dev) - EXTERNAL DATABASE**
|
||||
⚠️ **DO NOT create PostgreSQL databases on spdy.io infrastructure for cannabrands.**
|
||||
Cannabrands uses an external managed PostgreSQL database.
|
||||
```
|
||||
Host: 10.100.6.50
|
||||
Host: 10.100.6.50 (read replica)
|
||||
Port: 5432
|
||||
Database: cannabrands_dev
|
||||
Username: cannabrands
|
||||
@@ -128,8 +130,8 @@ Woodpecker secrets: `registry_user`, `registry_password`
|
||||
|
||||
**CI/CD Notes:**
|
||||
- Uses **Kaniko** for Docker builds (no Docker daemon, avoids DNS issues)
|
||||
- Images pushed to `git.spdy.io/cannabrands/hub` (k8s can pull without insecure config)
|
||||
- Base images pulled from local registry `10.100.9.70:5000` (Kaniko handles insecure)
|
||||
- Images pushed to `registry.spdy.io/cannabrands/hub`
|
||||
- Base images pulled from `registry.spdy.io` (HTTPS with Let's Encrypt)
|
||||
- Deploy: `develop` → dev.cannabrands.app, `master` → cannabrands.app
|
||||
|
||||
### 8. User-Business Relationship (Pivot Table)
|
||||
|
||||
@@ -44,7 +44,7 @@ Our workflow provides audit trails regulators love:
|
||||
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone https://code.cannabrands.app/Cannabrands/hub.git
|
||||
git clone https://git.spdy.io/Cannabrands/hub.git
|
||||
cd hub
|
||||
```
|
||||
|
||||
@@ -86,7 +86,7 @@ git commit -m "feat: add new feature"
|
||||
git push origin feature/my-feature-name
|
||||
|
||||
# 4. Create Pull Request on Gitea
|
||||
# - Navigate to https://code.cannabrands.app
|
||||
# - Navigate to https://git.spdy.io
|
||||
# - Create PR to merge your branch into develop
|
||||
# - CI will run automatically
|
||||
# - Request review from team
|
||||
@@ -630,7 +630,7 @@ git push origin chore/changelog-2025.11.1
|
||||
|
||||
### Services
|
||||
- **Woodpecker CI:** `https://ci.cannabrands.app`
|
||||
- **Gitea:** `https://code.cannabrands.app`
|
||||
- **Gitea:** `https://git.spdy.io`
|
||||
- **Production:** `https://app.cannabrands.com` (future)
|
||||
|
||||
---
|
||||
|
||||
144
app/Console/Commands/MigrateDbaData.php
Normal file
144
app/Console/Commands/MigrateDbaData.php
Normal file
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Models\BusinessDba;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Migrate existing business DBA data to the new business_dbas table.
|
||||
*
|
||||
* This command creates DBA records from existing business fields:
|
||||
* - dba_name
|
||||
* - invoice_payable_company_name, invoice_payable_address, etc.
|
||||
* - ap_contact_* fields
|
||||
* - primary_contact_* fields
|
||||
*/
|
||||
class MigrateDbaData extends Command
|
||||
{
|
||||
protected $signature = 'dba:migrate
|
||||
{--dry-run : Show what would be created without actually creating records}
|
||||
{--business= : Migrate only a specific business by ID or slug}
|
||||
{--force : Skip confirmation prompt}';
|
||||
|
||||
protected $description = 'Migrate existing dba_name and invoice_payable_* fields to the business_dbas table';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('DBA Data Migration');
|
||||
$this->line('==================');
|
||||
|
||||
$dryRun = $this->option('dry-run');
|
||||
$specificBusiness = $this->option('business');
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('DRY RUN MODE - No records will be created');
|
||||
}
|
||||
|
||||
// Build query
|
||||
$query = Business::query()
|
||||
->whereNotNull('dba_name')
|
||||
->where('dba_name', '!=', '');
|
||||
|
||||
if ($specificBusiness) {
|
||||
$query->where(function ($q) use ($specificBusiness) {
|
||||
$q->where('id', $specificBusiness)
|
||||
->orWhere('slug', $specificBusiness);
|
||||
});
|
||||
}
|
||||
|
||||
$businesses = $query->get();
|
||||
$this->info("Found {$businesses->count()} businesses with dba_name set.");
|
||||
|
||||
if ($businesses->isEmpty()) {
|
||||
$this->info('No businesses to migrate.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// Show preview
|
||||
$this->newLine();
|
||||
$this->table(
|
||||
['ID', 'Business Name', 'DBA Name', 'Has Invoice Address', 'Already Has DBAs'],
|
||||
$businesses->map(fn ($b) => [
|
||||
$b->id,
|
||||
\Illuminate\Support\Str::limit($b->name, 30),
|
||||
\Illuminate\Support\Str::limit($b->dba_name, 30),
|
||||
$b->invoice_payable_address ? 'Yes' : 'No',
|
||||
$b->dbas()->exists() ? 'Yes' : 'No',
|
||||
])
|
||||
);
|
||||
|
||||
if (! $dryRun && ! $this->option('force')) {
|
||||
if (! $this->confirm('Do you want to proceed with creating DBA records?')) {
|
||||
$this->info('Aborted.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
$created = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($businesses as $business) {
|
||||
// Skip if business already has DBAs
|
||||
if ($business->dbas()->exists()) {
|
||||
$this->line(" Skipping {$business->name} - already has DBAs");
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(" Would create DBA for: {$business->name} -> {$business->dba_name}");
|
||||
$created++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create DBA from existing business fields
|
||||
$dba = BusinessDba::create([
|
||||
'business_id' => $business->id,
|
||||
'trade_name' => $business->dba_name,
|
||||
|
||||
// Address - prefer invoice_payable fields, fall back to physical
|
||||
'address' => $business->invoice_payable_address ?: $business->physical_address,
|
||||
'city' => $business->invoice_payable_city ?: $business->physical_city,
|
||||
'state' => $business->invoice_payable_state ?: $business->physical_state,
|
||||
'zip' => $business->invoice_payable_zipcode ?: $business->physical_zipcode,
|
||||
|
||||
// License
|
||||
'license_number' => $business->license_number,
|
||||
'license_type' => $business->license_type,
|
||||
|
||||
// Contacts
|
||||
'primary_contact_name' => trim(($business->primary_contact_first_name ?? '').' '.($business->primary_contact_last_name ?? '')) ?: null,
|
||||
'primary_contact_email' => $business->primary_contact_email,
|
||||
'primary_contact_phone' => $business->primary_contact_phone,
|
||||
'ap_contact_name' => trim(($business->ap_contact_first_name ?? '').' '.($business->ap_contact_last_name ?? '')) ?: null,
|
||||
'ap_contact_email' => $business->ap_contact_email,
|
||||
'ap_contact_phone' => $business->ap_contact_phone,
|
||||
|
||||
// Invoice Settings
|
||||
'invoice_footer' => $business->order_invoice_footer,
|
||||
|
||||
// Status
|
||||
'is_default' => true,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->info(" Created DBA #{$dba->id} for {$business->name}: {$dba->trade_name}");
|
||||
$created++;
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("Summary: {$created} created, {$skipped} skipped");
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('Run without --dry-run to actually create records.');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,20 @@ class Kernel extends ConsoleKernel
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// BANNER ADS
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Update banner ad statuses (activate scheduled, expire ended) - every minute
|
||||
$schedule->job(new \App\Jobs\UpdateBannerAdStatuses)
|
||||
->everyMinute()
|
||||
->withoutOverlapping();
|
||||
|
||||
// Rollup daily banner ad stats - daily at 2 AM
|
||||
$schedule->job(new \App\Jobs\RollupBannerAdStats)
|
||||
->dailyAt('02:00')
|
||||
->withoutOverlapping();
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// HOUSEKEEPING & MAINTENANCE
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
41
app/Enums/BannerAdStatus.php
Normal file
41
app/Enums/BannerAdStatus.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum BannerAdStatus: string
|
||||
{
|
||||
case DRAFT = 'draft';
|
||||
case ACTIVE = 'active';
|
||||
case SCHEDULED = 'scheduled';
|
||||
case PAUSED = 'paused';
|
||||
case EXPIRED = 'expired';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::DRAFT => 'Draft',
|
||||
self::ACTIVE => 'Active',
|
||||
self::SCHEDULED => 'Scheduled',
|
||||
self::PAUSED => 'Paused',
|
||||
self::EXPIRED => 'Expired',
|
||||
};
|
||||
}
|
||||
|
||||
public function color(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::DRAFT => 'gray',
|
||||
self::ACTIVE => 'success',
|
||||
self::SCHEDULED => 'info',
|
||||
self::PAUSED => 'warning',
|
||||
self::EXPIRED => 'danger',
|
||||
};
|
||||
}
|
||||
|
||||
public static function options(): array
|
||||
{
|
||||
return collect(self::cases())->mapWithKeys(fn (self $status) => [
|
||||
$status->value => $status->label(),
|
||||
])->toArray();
|
||||
}
|
||||
}
|
||||
51
app/Enums/BannerAdZone.php
Normal file
51
app/Enums/BannerAdZone.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum BannerAdZone: string
|
||||
{
|
||||
case MARKETPLACE_HERO = 'marketplace_hero';
|
||||
case MARKETPLACE_LEADERBOARD = 'marketplace_leaderboard';
|
||||
case MARKETPLACE_SIDEBAR = 'marketplace_sidebar';
|
||||
case MARKETPLACE_INLINE = 'marketplace_inline';
|
||||
case BRAND_PAGE_BANNER = 'brand_page_banner';
|
||||
case DEALS_PAGE_HERO = 'deals_page_hero';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::MARKETPLACE_HERO => 'Marketplace Hero (Full Width)',
|
||||
self::MARKETPLACE_LEADERBOARD => 'Marketplace Leaderboard (728x90)',
|
||||
self::MARKETPLACE_SIDEBAR => 'Marketplace Sidebar (300x250)',
|
||||
self::MARKETPLACE_INLINE => 'Marketplace Inline (Between Products)',
|
||||
self::BRAND_PAGE_BANNER => 'Brand Page Banner',
|
||||
self::DEALS_PAGE_HERO => 'Deals Page Hero',
|
||||
};
|
||||
}
|
||||
|
||||
public function dimensions(): array
|
||||
{
|
||||
return match ($this) {
|
||||
self::MARKETPLACE_HERO => ['width' => 1920, 'height' => 400, 'display' => '1920x400'],
|
||||
self::MARKETPLACE_LEADERBOARD => ['width' => 728, 'height' => 90, 'display' => '728x90'],
|
||||
self::MARKETPLACE_SIDEBAR => ['width' => 300, 'height' => 250, 'display' => '300x250'],
|
||||
self::MARKETPLACE_INLINE => ['width' => 970, 'height' => 250, 'display' => '970x250'],
|
||||
self::BRAND_PAGE_BANNER => ['width' => 1344, 'height' => 280, 'display' => '1344x280'],
|
||||
self::DEALS_PAGE_HERO => ['width' => 1920, 'height' => 350, 'display' => '1920x350'],
|
||||
};
|
||||
}
|
||||
|
||||
public static function options(): array
|
||||
{
|
||||
return collect(self::cases())->mapWithKeys(fn (self $zone) => [
|
||||
$zone->value => $zone->label().' - '.$zone->dimensions()['display'],
|
||||
])->toArray();
|
||||
}
|
||||
|
||||
public static function optionsSimple(): array
|
||||
{
|
||||
return collect(self::cases())->mapWithKeys(fn (self $zone) => [
|
||||
$zone->value => $zone->label(),
|
||||
])->toArray();
|
||||
}
|
||||
}
|
||||
47
app/Events/TeamMessageSent.php
Normal file
47
app/Events/TeamMessageSent.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\TeamMessage;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class TeamMessageSent implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public TeamMessage $message
|
||||
) {}
|
||||
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
// Broadcast to the team conversation channel
|
||||
return [
|
||||
new PrivateChannel('team-conversation.'.$this->message->conversation_id),
|
||||
];
|
||||
}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'message.sent';
|
||||
}
|
||||
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->message->id,
|
||||
'conversation_id' => $this->message->conversation_id,
|
||||
'sender_id' => $this->message->sender_id,
|
||||
'sender_name' => $this->message->getSenderName(),
|
||||
'sender_initials' => $this->message->getSenderInitials(),
|
||||
'body' => $this->message->body,
|
||||
'type' => $this->message->type,
|
||||
'metadata' => $this->message->metadata,
|
||||
'created_at' => $this->message->created_at->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
318
app/Filament/Resources/BannerAdResource.php
Normal file
318
app/Filament/Resources/BannerAdResource.php
Normal file
@@ -0,0 +1,318 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Enums\BannerAdStatus;
|
||||
use App\Enums\BannerAdZone;
|
||||
use App\Filament\Resources\BannerAdResource\Pages;
|
||||
use App\Models\BannerAd;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Actions\ForceDeleteBulkAction;
|
||||
use Filament\Actions\RestoreBulkAction;
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\FileUpload;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\SoftDeletingScope;
|
||||
use UnitEnum;
|
||||
|
||||
class BannerAdResource extends Resource
|
||||
{
|
||||
protected static ?string $model = BannerAd::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedPhoto;
|
||||
|
||||
protected static UnitEnum|string|null $navigationGroup = 'Marketing';
|
||||
|
||||
protected static ?int $navigationSort = 10;
|
||||
|
||||
protected static ?string $navigationLabel = 'Banner Ads';
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
// Hide this resource if the banner_ads table doesn't exist yet
|
||||
if (! \Illuminate\Support\Facades\Schema::hasTable('banner_ads')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parent::canAccess();
|
||||
}
|
||||
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
return cache()->remember('banner_ad_active_count', 60, function () {
|
||||
// Handle case where migrations haven't been run yet
|
||||
if (! \Illuminate\Support\Facades\Schema::hasTable('banner_ads')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$count = static::getModel()::where('status', BannerAdStatus::ACTIVE)->count();
|
||||
|
||||
return $count ?: null;
|
||||
} catch (\Exception $e) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make('Basic Information')
|
||||
->columns(2)
|
||||
->schema([
|
||||
TextInput::make('name')
|
||||
->label('Internal Name')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->helperText('Internal reference name (not shown to users)'),
|
||||
|
||||
Select::make('zone')
|
||||
->label('Ad Zone')
|
||||
->options(BannerAdZone::options())
|
||||
->required()
|
||||
->live()
|
||||
->afterStateUpdated(fn ($state, $set) => $state
|
||||
? $set('zone_info', BannerAdZone::from($state)->dimensions()['display'])
|
||||
: $set('zone_info', null)),
|
||||
|
||||
Placeholder::make('zone_info')
|
||||
->label('Recommended Dimensions')
|
||||
->content(fn ($get) => $get('zone')
|
||||
? BannerAdZone::from($get('zone'))->dimensions()['display']
|
||||
: 'Select a zone'),
|
||||
|
||||
Select::make('status')
|
||||
->options(BannerAdStatus::options())
|
||||
->default('draft')
|
||||
->required(),
|
||||
|
||||
Select::make('brand_id')
|
||||
->label('Brand (Optional)')
|
||||
->relationship('brand', 'name')
|
||||
->searchable()
|
||||
->preload()
|
||||
->helperText('Leave empty for platform-wide ads'),
|
||||
]),
|
||||
|
||||
Section::make('Creative Content')
|
||||
->columns(2)
|
||||
->schema([
|
||||
FileUpload::make('image_path')
|
||||
->label('Banner Image')
|
||||
->image()
|
||||
->required()
|
||||
->disk('minio')
|
||||
->directory('banner-ads')
|
||||
->visibility('public')
|
||||
->maxSize(5120)
|
||||
->helperText('Upload banner image at recommended dimensions')
|
||||
->columnSpanFull(),
|
||||
|
||||
TextInput::make('image_alt')
|
||||
->label('Alt Text')
|
||||
->maxLength(255)
|
||||
->helperText('Accessibility description'),
|
||||
|
||||
TextInput::make('headline')
|
||||
->maxLength(100)
|
||||
->helperText('Optional overlay headline'),
|
||||
|
||||
Textarea::make('description')
|
||||
->maxLength(200)
|
||||
->helperText('Optional overlay description'),
|
||||
|
||||
TextInput::make('cta_text')
|
||||
->label('Button Text')
|
||||
->maxLength(50)
|
||||
->placeholder('Shop Now')
|
||||
->helperText('Call-to-action button text'),
|
||||
|
||||
TextInput::make('cta_url')
|
||||
->label('Destination URL')
|
||||
->required()
|
||||
->url()
|
||||
->maxLength(500)
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
|
||||
Section::make('Scheduling')
|
||||
->columns(2)
|
||||
->schema([
|
||||
DateTimePicker::make('starts_at')
|
||||
->label('Start Date')
|
||||
->helperText('Leave empty to start immediately'),
|
||||
|
||||
DateTimePicker::make('ends_at')
|
||||
->label('End Date')
|
||||
->helperText('Leave empty to run indefinitely'),
|
||||
]),
|
||||
|
||||
Section::make('Targeting & Priority')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Toggle::make('is_platform_wide')
|
||||
->label('Platform Wide')
|
||||
->default(true)
|
||||
->helperText('Show to all users'),
|
||||
|
||||
Select::make('target_business_types')
|
||||
->label('Target Business Types')
|
||||
->multiple()
|
||||
->options([
|
||||
'buyer' => 'Buyers (Dispensaries)',
|
||||
'seller' => 'Sellers (Brands)',
|
||||
])
|
||||
->helperText('Leave empty for all types'),
|
||||
|
||||
TextInput::make('priority')
|
||||
->numeric()
|
||||
->default(0)
|
||||
->helperText('Higher = shown first (0-100)'),
|
||||
|
||||
TextInput::make('weight')
|
||||
->numeric()
|
||||
->default(100)
|
||||
->minValue(1)
|
||||
->maxValue(1000)
|
||||
->helperText('Weight for random rotation (1-1000)'),
|
||||
]),
|
||||
|
||||
Section::make('Analytics')
|
||||
->columns(3)
|
||||
->schema([
|
||||
Placeholder::make('impressions_display')
|
||||
->label('Impressions')
|
||||
->content(fn (?BannerAd $record) => number_format($record?->impressions ?? 0)),
|
||||
|
||||
Placeholder::make('clicks_display')
|
||||
->label('Clicks')
|
||||
->content(fn (?BannerAd $record) => number_format($record?->clicks ?? 0)),
|
||||
|
||||
Placeholder::make('ctr_display')
|
||||
->label('CTR')
|
||||
->content(fn (?BannerAd $record) => ($record?->click_through_rate ?? 0).'%'),
|
||||
])
|
||||
->hiddenOn('create'),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\ImageColumn::make('image_path')
|
||||
->label('Preview')
|
||||
->disk('minio')
|
||||
->width(120)
|
||||
->height(60),
|
||||
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->weight('bold'),
|
||||
|
||||
Tables\Columns\TextColumn::make('zone')
|
||||
->badge()
|
||||
->formatStateUsing(fn ($state) => $state instanceof BannerAdZone
|
||||
? $state->label()
|
||||
: BannerAdZone::tryFrom($state)?->label() ?? $state),
|
||||
|
||||
Tables\Columns\TextColumn::make('status')
|
||||
->badge()
|
||||
->color(fn ($state) => $state instanceof BannerAdStatus
|
||||
? $state->color()
|
||||
: BannerAdStatus::tryFrom($state)?->color() ?? 'gray'),
|
||||
|
||||
Tables\Columns\TextColumn::make('impressions')
|
||||
->numeric()
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
|
||||
Tables\Columns\TextColumn::make('clicks')
|
||||
->numeric()
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
|
||||
Tables\Columns\TextColumn::make('click_through_rate')
|
||||
->label('CTR')
|
||||
->suffix('%')
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
|
||||
Tables\Columns\TextColumn::make('starts_at')
|
||||
->dateTime('M j, Y')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
|
||||
Tables\Columns\TextColumn::make('ends_at')
|
||||
->dateTime('M j, Y')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->defaultSort('created_at', 'desc')
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('status')
|
||||
->options(BannerAdStatus::options()),
|
||||
Tables\Filters\SelectFilter::make('zone')
|
||||
->options(BannerAdZone::optionsSimple()),
|
||||
Tables\Filters\TrashedFilter::make(),
|
||||
])
|
||||
->actions([
|
||||
ViewAction::make(),
|
||||
EditAction::make(),
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
ForceDeleteBulkAction::make(),
|
||||
RestoreBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListBannerAds::route('/'),
|
||||
'create' => Pages\CreateBannerAd::route('/create'),
|
||||
'view' => Pages\ViewBannerAd::route('/{record}'),
|
||||
'edit' => Pages\EditBannerAd::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
return parent::getEloquentQuery()
|
||||
->withoutGlobalScopes([
|
||||
SoftDeletingScope::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BannerAdResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BannerAdResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateBannerAd extends CreateRecord
|
||||
{
|
||||
protected static string $resource = BannerAdResource::class;
|
||||
|
||||
protected function mutateFormDataBeforeCreate(array $data): array
|
||||
{
|
||||
$data['created_by_user_id'] = auth()->id();
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
protected function getRedirectUrl(): string
|
||||
{
|
||||
return $this->getResource()::getUrl('index');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BannerAdResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BannerAdResource;
|
||||
use App\Services\BannerAdService;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditBannerAd extends EditRecord
|
||||
{
|
||||
protected static string $resource = BannerAdResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\ViewAction::make(),
|
||||
Actions\DeleteAction::make(),
|
||||
Actions\ForceDeleteAction::make(),
|
||||
Actions\RestoreAction::make(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function afterSave(): void
|
||||
{
|
||||
// Clear caches when banner ad is updated
|
||||
app(BannerAdService::class)->clearAllCaches();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BannerAdResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BannerAdResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListBannerAds extends ListRecords
|
||||
{
|
||||
protected static string $resource = BannerAdResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BannerAdResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BannerAdResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewBannerAd extends ViewRecord
|
||||
{
|
||||
protected static string $resource = BannerAdResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\EditAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -699,6 +699,11 @@ class BusinessResource extends Resource
|
||||
'</div>'
|
||||
);
|
||||
}),
|
||||
|
||||
Toggle::make('ping_pong_enabled')
|
||||
->label('Ping Pong Order Flow')
|
||||
->helperText('When enabled, buyers and sellers can send order details back and forth during the order process. Shows order progress stages and enables collaborative order editing.')
|
||||
->default(false),
|
||||
]),
|
||||
|
||||
// ===== SUITE ASSIGNMENT SECTION =====
|
||||
@@ -2082,6 +2087,7 @@ class BusinessResource extends Resource
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
BusinessResource\RelationManagers\DbasRelationManager::class,
|
||||
\Tapp\FilamentAuditing\RelationManagers\AuditsRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BusinessResource\RelationManagers;
|
||||
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Forms\Components\Grid;
|
||||
use Filament\Forms\Components\Section;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class DbasRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'dbas';
|
||||
|
||||
protected static ?string $title = 'Trade Names (DBAs)';
|
||||
|
||||
protected static ?string $recordTitleAttribute = 'trade_name';
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make('Basic Information')
|
||||
->schema([
|
||||
TextInput::make('trade_name')
|
||||
->label('Trade Name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('slug')
|
||||
->label('Slug')
|
||||
->disabled()
|
||||
->dehydrated(false)
|
||||
->helperText('Auto-generated from trade name'),
|
||||
Toggle::make('is_default')
|
||||
->label('Default DBA')
|
||||
->helperText('Use for new invoices by default'),
|
||||
Toggle::make('is_active')
|
||||
->label('Active')
|
||||
->default(true),
|
||||
])
|
||||
->columns(2),
|
||||
|
||||
Section::make('Address')
|
||||
->schema([
|
||||
TextInput::make('address')
|
||||
->label('Street Address')
|
||||
->maxLength(255),
|
||||
TextInput::make('address_line_2')
|
||||
->label('Address Line 2')
|
||||
->maxLength(255),
|
||||
Grid::make(3)
|
||||
->schema([
|
||||
TextInput::make('city')
|
||||
->maxLength(255),
|
||||
TextInput::make('state')
|
||||
->maxLength(2)
|
||||
->extraAttributes(['class' => 'uppercase']),
|
||||
TextInput::make('zip')
|
||||
->label('ZIP Code')
|
||||
->maxLength(10),
|
||||
]),
|
||||
])
|
||||
->collapsible(),
|
||||
|
||||
Section::make('License Information')
|
||||
->schema([
|
||||
TextInput::make('license_number')
|
||||
->maxLength(255),
|
||||
TextInput::make('license_type')
|
||||
->maxLength(255),
|
||||
DatePicker::make('license_expiration')
|
||||
->label('Expiration Date'),
|
||||
])
|
||||
->columns(3)
|
||||
->collapsible(),
|
||||
|
||||
Section::make('Banking Information')
|
||||
->description('Sensitive data is encrypted at rest.')
|
||||
->schema([
|
||||
TextInput::make('bank_name')
|
||||
->maxLength(255),
|
||||
TextInput::make('bank_account_name')
|
||||
->maxLength(255),
|
||||
TextInput::make('bank_routing_number')
|
||||
->maxLength(50)
|
||||
->password()
|
||||
->revealable(),
|
||||
TextInput::make('bank_account_number')
|
||||
->maxLength(50)
|
||||
->password()
|
||||
->revealable(),
|
||||
Select::make('bank_account_type')
|
||||
->options([
|
||||
'checking' => 'Checking',
|
||||
'savings' => 'Savings',
|
||||
]),
|
||||
])
|
||||
->columns(2)
|
||||
->collapsible()
|
||||
->collapsed(),
|
||||
|
||||
Section::make('Tax Information')
|
||||
->description('Sensitive data is encrypted at rest.')
|
||||
->schema([
|
||||
TextInput::make('tax_id')
|
||||
->label('Tax ID')
|
||||
->maxLength(50)
|
||||
->password()
|
||||
->revealable(),
|
||||
Select::make('tax_id_type')
|
||||
->label('Tax ID Type')
|
||||
->options([
|
||||
'ein' => 'EIN',
|
||||
'ssn' => 'SSN',
|
||||
]),
|
||||
])
|
||||
->columns(2)
|
||||
->collapsible()
|
||||
->collapsed(),
|
||||
|
||||
Section::make('Contacts')
|
||||
->schema([
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
Section::make('Primary Contact')
|
||||
->schema([
|
||||
TextInput::make('primary_contact_name')
|
||||
->label('Name')
|
||||
->maxLength(255),
|
||||
TextInput::make('primary_contact_email')
|
||||
->label('Email')
|
||||
->email()
|
||||
->maxLength(255),
|
||||
TextInput::make('primary_contact_phone')
|
||||
->label('Phone')
|
||||
->tel()
|
||||
->maxLength(50),
|
||||
]),
|
||||
Section::make('AP Contact')
|
||||
->schema([
|
||||
TextInput::make('ap_contact_name')
|
||||
->label('Name')
|
||||
->maxLength(255),
|
||||
TextInput::make('ap_contact_email')
|
||||
->label('Email')
|
||||
->email()
|
||||
->maxLength(255),
|
||||
TextInput::make('ap_contact_phone')
|
||||
->label('Phone')
|
||||
->tel()
|
||||
->maxLength(50),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
->collapsible()
|
||||
->collapsed(),
|
||||
|
||||
Section::make('Invoice Settings')
|
||||
->schema([
|
||||
TextInput::make('payment_terms')
|
||||
->maxLength(50)
|
||||
->placeholder('Net 30'),
|
||||
TextInput::make('invoice_prefix')
|
||||
->maxLength(10)
|
||||
->placeholder('INV-'),
|
||||
Textarea::make('payment_instructions')
|
||||
->rows(2)
|
||||
->columnSpanFull(),
|
||||
Textarea::make('invoice_footer')
|
||||
->rows(2)
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columns(2)
|
||||
->collapsible()
|
||||
->collapsed(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('trade_name')
|
||||
->label('Trade Name')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('city')
|
||||
->label('Location')
|
||||
->formatStateUsing(fn ($record) => $record->city && $record->state
|
||||
? "{$record->city}, {$record->state}"
|
||||
: ($record->city ?? $record->state ?? '-'))
|
||||
->sortable(),
|
||||
TextColumn::make('license_number')
|
||||
->label('License')
|
||||
->limit(15)
|
||||
->tooltip(fn ($state) => $state),
|
||||
IconColumn::make('is_default')
|
||||
->label('Default')
|
||||
->boolean()
|
||||
->trueIcon('heroicon-o-star')
|
||||
->falseIcon('heroicon-o-minus')
|
||||
->trueColor('warning'),
|
||||
IconColumn::make('is_active')
|
||||
->label('Active')
|
||||
->boolean(),
|
||||
TextColumn::make('created_at')
|
||||
->label('Created')
|
||||
->dateTime('M j, Y')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->defaultSort('is_default', 'desc')
|
||||
->headerActions([
|
||||
CreateAction::make(),
|
||||
])
|
||||
->actions([
|
||||
EditAction::make(),
|
||||
DeleteAction::make()
|
||||
->requiresConfirmation(),
|
||||
])
|
||||
->emptyStateHeading('No Trade Names')
|
||||
->emptyStateDescription('Add a DBA to manage different trade names for invoices and licenses.')
|
||||
->emptyStateIcon('heroicon-o-building-office-2');
|
||||
}
|
||||
}
|
||||
237
app/Http/Controllers/Api/TeamChatController.php
Normal file
237
app/Http/Controllers/Api/TeamChatController.php
Normal file
@@ -0,0 +1,237 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\TeamConversation;
|
||||
use App\Models\TeamMessage;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class TeamChatController extends Controller
|
||||
{
|
||||
/**
|
||||
* Get all team conversations for current user
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'business_id' => 'required|integer|exists:businesses,id',
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
// Verify user belongs to business
|
||||
if (! $user->businesses()->where('businesses.id', $validated['business_id'])->exists()) {
|
||||
return response()->json(['error' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$conversations = TeamConversation::forBusiness($validated['business_id'])
|
||||
->forUser($user->id)
|
||||
->with(['participants:id,first_name,last_name', 'messages' => fn ($q) => $q->latest()->limit(1)])
|
||||
->orderByDesc('last_message_at')
|
||||
->get()
|
||||
->map(fn ($conv) => $this->formatConversation($conv, $user->id));
|
||||
|
||||
return response()->json(['conversations' => $conversations]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a direct conversation with another user
|
||||
*/
|
||||
public function getOrCreateDirect(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'business_id' => 'required|integer|exists:businesses,id',
|
||||
'user_id' => 'required|integer|exists:users,id',
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
// Verify current user belongs to business
|
||||
if (! $user->businesses()->where('businesses.id', $validated['business_id'])->exists()) {
|
||||
return response()->json(['error' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
// Verify target user belongs to same business
|
||||
$targetUser = User::find($validated['user_id']);
|
||||
if (! $targetUser->businesses()->where('businesses.id', $validated['business_id'])->exists()) {
|
||||
return response()->json(['error' => 'User not in business'], 400);
|
||||
}
|
||||
|
||||
// Can't chat with yourself
|
||||
if ($validated['user_id'] === $user->id) {
|
||||
return response()->json(['error' => 'Cannot chat with yourself'], 400);
|
||||
}
|
||||
|
||||
$conversation = TeamConversation::getOrCreateDirect(
|
||||
$validated['business_id'],
|
||||
$user->id,
|
||||
$validated['user_id']
|
||||
);
|
||||
|
||||
$conversation->load('participants:id,first_name,last_name');
|
||||
|
||||
return response()->json([
|
||||
'conversation' => $this->formatConversation($conversation, $user->id),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get messages for a conversation
|
||||
*/
|
||||
public function messages(Request $request, int $conversationId): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$conversation = TeamConversation::with('participants')
|
||||
->findOrFail($conversationId);
|
||||
|
||||
// Verify user is participant
|
||||
if (! $conversation->participants->contains('id', $user->id)) {
|
||||
return response()->json(['error' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$messages = $conversation->messages()
|
||||
->with('sender:id,first_name,last_name')
|
||||
->orderBy('created_at')
|
||||
->limit(100)
|
||||
->get()
|
||||
->map(fn ($msg) => $this->formatMessage($msg));
|
||||
|
||||
// Mark conversation as read
|
||||
$conversation->markReadFor($user->id);
|
||||
|
||||
return response()->json(['messages' => $messages]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to a conversation
|
||||
*/
|
||||
public function send(Request $request, int $conversationId): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'body' => 'required|string|max:10000',
|
||||
'type' => 'sometimes|string|in:text,file,image',
|
||||
'metadata' => 'sometimes|array',
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
$conversation = TeamConversation::with('participants')
|
||||
->findOrFail($conversationId);
|
||||
|
||||
// Verify user is participant
|
||||
if (! $conversation->participants->contains('id', $user->id)) {
|
||||
return response()->json(['error' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$message = TeamMessage::create([
|
||||
'conversation_id' => $conversationId,
|
||||
'sender_id' => $user->id,
|
||||
'body' => $validated['body'],
|
||||
'type' => $validated['type'] ?? TeamMessage::TYPE_TEXT,
|
||||
'metadata' => $validated['metadata'] ?? null,
|
||||
'read_by' => [$user->id], // Sender has read it
|
||||
]);
|
||||
|
||||
$message->load('sender:id,first_name,last_name');
|
||||
|
||||
return response()->json([
|
||||
'message' => $this->formatMessage($message),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark conversation as read
|
||||
*/
|
||||
public function markRead(Request $request, int $conversationId): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$conversation = TeamConversation::with('participants')
|
||||
->findOrFail($conversationId);
|
||||
|
||||
// Verify user is participant
|
||||
if (! $conversation->participants->contains('id', $user->id)) {
|
||||
return response()->json(['error' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$conversation->markReadFor($user->id);
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get team members available for chat
|
||||
*/
|
||||
public function teamMembers(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'business_id' => 'required|integer|exists:businesses,id',
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
// Verify user belongs to business
|
||||
if (! $user->businesses()->where('businesses.id', $validated['business_id'])->exists()) {
|
||||
return response()->json(['error' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
// Get all users in the business except current user
|
||||
$members = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $validated['business_id']))
|
||||
->where('id', '!=', $user->id)
|
||||
->select('id', 'first_name', 'last_name')
|
||||
->orderBy('first_name')
|
||||
->get()
|
||||
->map(fn ($u) => [
|
||||
'id' => $u->id,
|
||||
'name' => $u->name,
|
||||
'initials' => strtoupper(substr($u->first_name ?? '', 0, 1).substr($u->last_name ?? '', 0, 1)),
|
||||
]);
|
||||
|
||||
return response()->json(['members' => $members]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format conversation for API response
|
||||
*/
|
||||
private function formatConversation(TeamConversation $conversation, int $currentUserId): array
|
||||
{
|
||||
$other = $conversation->getOtherParticipant($currentUserId);
|
||||
|
||||
return [
|
||||
'id' => $conversation->id,
|
||||
'type' => $conversation->type,
|
||||
'name' => $conversation->getDisplayName($currentUserId),
|
||||
'other_user' => $other ? [
|
||||
'id' => $other->id,
|
||||
'name' => $other->name,
|
||||
'initials' => strtoupper(substr($other->first_name ?? '', 0, 1).substr($other->last_name ?? '', 0, 1)),
|
||||
] : null,
|
||||
'last_message_preview' => $conversation->last_message_preview,
|
||||
'last_message_at' => $conversation->last_message_at?->toIso8601String(),
|
||||
'unread_count' => $conversation->getUnreadCountFor($currentUserId),
|
||||
'is_pinned' => $conversation->participants->firstWhere('id', $currentUserId)?->pivot?->is_pinned ?? false,
|
||||
'is_muted' => $conversation->participants->firstWhere('id', $currentUserId)?->pivot?->is_muted ?? false,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format message for API response
|
||||
*/
|
||||
private function formatMessage(TeamMessage $message): array
|
||||
{
|
||||
return [
|
||||
'id' => $message->id,
|
||||
'sender_id' => $message->sender_id,
|
||||
'sender_name' => $message->getSenderName(),
|
||||
'sender_initials' => $message->getSenderInitials(),
|
||||
'body' => $message->body,
|
||||
'type' => $message->type,
|
||||
'metadata' => $message->metadata,
|
||||
'created_at' => $message->created_at->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
96
app/Http/Controllers/BannerAdController.php
Normal file
96
app/Http/Controllers/BannerAdController.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\BannerAd;
|
||||
use App\Services\BannerAdService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Intervention\Image\Drivers\Gd\Driver;
|
||||
use Intervention\Image\ImageManager;
|
||||
|
||||
class BannerAdController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected BannerAdService $bannerAdService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Handle click tracking and redirect
|
||||
* URL: /ads/click/{bannerAd}
|
||||
*/
|
||||
public function click(Request $request, BannerAd $bannerAd)
|
||||
{
|
||||
$this->bannerAdService->recordClick($bannerAd, [
|
||||
'business_id' => auth()->user()?->businesses->first()?->id,
|
||||
'user_id' => auth()->id(),
|
||||
'session_id' => session()->getId(),
|
||||
'page_url' => $request->header('referer'),
|
||||
]);
|
||||
|
||||
return redirect()->away($bannerAd->cta_url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track impression via AJAX (for lazy-loaded ads)
|
||||
* URL: POST /ads/impression/{bannerAd}
|
||||
*/
|
||||
public function impression(Request $request, BannerAd $bannerAd)
|
||||
{
|
||||
$this->bannerAdService->recordImpression($bannerAd, [
|
||||
'business_id' => auth()->user()?->businesses->first()?->id,
|
||||
'user_id' => auth()->id(),
|
||||
'session_id' => session()->getId(),
|
||||
]);
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve banner ad image at specific width
|
||||
* URL: /images/banner-ad/{bannerAd}/{width?}
|
||||
*/
|
||||
public function image(BannerAd $bannerAd, ?int $width = null)
|
||||
{
|
||||
if (! $bannerAd->image_path || ! Storage::exists($bannerAd->image_path)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// Return original if no width specified
|
||||
if (! $width) {
|
||||
$contents = Storage::get($bannerAd->image_path);
|
||||
$mimeType = Storage::mimeType($bannerAd->image_path);
|
||||
|
||||
return response($contents)
|
||||
->header('Content-Type', $mimeType)
|
||||
->header('Cache-Control', 'public, max-age=86400');
|
||||
}
|
||||
|
||||
// Generate and cache resized version
|
||||
$ext = pathinfo($bannerAd->image_path, PATHINFO_EXTENSION);
|
||||
$thumbnailName = "banner-ad-{$bannerAd->id}-{$width}w.{$ext}";
|
||||
$thumbnailPath = "banner-ads/cache/{$thumbnailName}";
|
||||
|
||||
if (! Storage::disk('local')->exists($thumbnailPath)) {
|
||||
$originalContents = Storage::get($bannerAd->image_path);
|
||||
|
||||
$manager = new ImageManager(new Driver);
|
||||
$image = $manager->read($originalContents);
|
||||
$image->scale(width: $width);
|
||||
|
||||
if (! Storage::disk('local')->exists('banner-ads/cache')) {
|
||||
Storage::disk('local')->makeDirectory('banner-ads/cache');
|
||||
}
|
||||
|
||||
$encoded = $ext === 'png' ? $image->toPng() : $image->toJpeg(quality: 90);
|
||||
Storage::disk('local')->put($thumbnailPath, $encoded);
|
||||
}
|
||||
|
||||
$mimeType = $ext === 'png' ? 'image/png' : 'image/jpeg';
|
||||
|
||||
return response()->file(
|
||||
storage_path("app/private/{$thumbnailPath}"),
|
||||
['Content-Type' => $mimeType, 'Cache-Control' => 'public, max-age=86400']
|
||||
);
|
||||
}
|
||||
}
|
||||
101
app/Http/Controllers/Buyer/BuyAgainController.php
Normal file
101
app/Http/Controllers/Buyer/BuyAgainController.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Buyer;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Brand;
|
||||
use App\Models\Business;
|
||||
use App\Models\Buyer\BuyerBrandFollow;
|
||||
use App\Models\OrderItem;
|
||||
use App\Services\Cannaiq\MarketingIntelligenceService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class BuyAgainController extends Controller
|
||||
{
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$tab = $request->get('tab', 'favorites'); // 'favorites' or 'history'
|
||||
|
||||
if ($tab === 'favorites') {
|
||||
$brands = $this->getFavoriteBrands($business);
|
||||
} else {
|
||||
$brands = $this->getPurchaseHistory($business);
|
||||
}
|
||||
|
||||
// Optional: Enrich with CannaIQ inventory data if business has it
|
||||
$storeMetrics = null;
|
||||
if ($business->cannaiq_store_id) {
|
||||
$storeMetrics = $this->getStoreInventory($business, $brands);
|
||||
}
|
||||
|
||||
return view('buyer.buy-again.index', compact('business', 'brands', 'tab', 'storeMetrics'));
|
||||
}
|
||||
|
||||
private function getFavoriteBrands(Business $business)
|
||||
{
|
||||
// Get brands the buyer follows
|
||||
$followedBrandIds = BuyerBrandFollow::where('business_id', $business->id)
|
||||
->pluck('brand_id');
|
||||
|
||||
if ($followedBrandIds->isEmpty()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
// Get products from those brands that user has ordered
|
||||
return Brand::whereIn('id', $followedBrandIds)
|
||||
->with(['products' => function ($query) use ($business) {
|
||||
$query->whereHas('orderItems.order', function ($q) use ($business) {
|
||||
$q->where('business_id', $business->id);
|
||||
})
|
||||
->with(['orderItems' => function ($q) use ($business) {
|
||||
$q->whereHas('order', fn ($o) => $o->where('business_id', $business->id))
|
||||
->latest()
|
||||
->limit(1);
|
||||
}])
|
||||
->where('is_active', true);
|
||||
}])
|
||||
->get()
|
||||
->filter(fn ($brand) => $brand->products->isNotEmpty());
|
||||
}
|
||||
|
||||
private function getPurchaseHistory(Business $business)
|
||||
{
|
||||
// Get all products ever ordered, grouped by brand
|
||||
$orderedProductIds = OrderItem::whereHas('order', function ($q) use ($business) {
|
||||
$q->where('business_id', $business->id);
|
||||
})->distinct()->pluck('product_id');
|
||||
|
||||
if ($orderedProductIds->isEmpty()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return Brand::whereHas('products', fn ($q) => $q->whereIn('id', $orderedProductIds))
|
||||
->with(['products' => function ($query) use ($orderedProductIds, $business) {
|
||||
$query->whereIn('id', $orderedProductIds)
|
||||
->with(['orderItems' => function ($q) use ($business) {
|
||||
$q->whereHas('order', fn ($o) => $o->where('business_id', $business->id))
|
||||
->latest()
|
||||
->limit(1);
|
||||
}]);
|
||||
}])
|
||||
->get();
|
||||
}
|
||||
|
||||
private function getStoreInventory(Business $business, $brands)
|
||||
{
|
||||
if ($brands->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$productIds = $brands->flatMap(fn ($b) => $b->products->pluck('id'));
|
||||
|
||||
try {
|
||||
$cannaiq = app(MarketingIntelligenceService::class);
|
||||
|
||||
return $cannaiq->getStoreMetrics($business->cannaiq_store_id, $productIds->toArray());
|
||||
} catch (\Exception $e) {
|
||||
// Silently fail if CannaIQ unavailable
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
90
app/Http/Controllers/Buyer/CompareController.php
Normal file
90
app/Http/Controllers/Buyer/CompareController.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Buyer;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Product;
|
||||
use App\Services\ProductComparisonService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class CompareController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected ProductComparisonService $comparison
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Show the comparison page.
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
$products = $this->comparison->getProducts();
|
||||
$business = auth()->user()->businesses->first();
|
||||
|
||||
return view('buyer.compare.index', compact('products', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current comparison state (AJAX).
|
||||
*/
|
||||
public function state(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'ids' => $this->comparison->getProductIds(),
|
||||
'count' => $this->comparison->count(),
|
||||
'is_full' => $this->comparison->isFull(),
|
||||
'max' => $this->comparison->maxItems(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a product in the comparison list (AJAX).
|
||||
*/
|
||||
public function toggle(Product $product): JsonResponse
|
||||
{
|
||||
if (! $product->is_active) {
|
||||
return response()->json(['error' => 'Product not found'], 404);
|
||||
}
|
||||
|
||||
$result = $this->comparison->toggle($product->id);
|
||||
|
||||
return response()->json([
|
||||
'added' => $result['added'],
|
||||
'count' => $result['count'],
|
||||
'is_full' => $this->comparison->isFull(),
|
||||
'message' => $result['added']
|
||||
? 'Added to comparison'
|
||||
: 'Removed from comparison',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a product from comparison list (AJAX).
|
||||
*/
|
||||
public function remove(Product $product): JsonResponse
|
||||
{
|
||||
$this->comparison->remove($product->id);
|
||||
|
||||
return response()->json([
|
||||
'count' => $this->comparison->count(),
|
||||
'is_full' => $this->comparison->isFull(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the comparison list.
|
||||
*/
|
||||
public function clear(): JsonResponse
|
||||
{
|
||||
$this->comparison->clear();
|
||||
|
||||
return response()->json([
|
||||
'count' => 0,
|
||||
'is_full' => false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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\Crm\CrmThread;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -10,9 +11,8 @@ use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class InboxController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$business = Auth::user()->business;
|
||||
$user = Auth::user();
|
||||
|
||||
$filter = $request->get('filter', 'all');
|
||||
@@ -20,7 +20,7 @@ class InboxController extends Controller
|
||||
|
||||
$query = CrmThread::forBuyerBusiness($business->id)
|
||||
->with(['brand', 'latestMessage', 'messages' => fn ($q) => $q->latest()->limit(1)])
|
||||
->withCount(['messages', 'unreadMessages as unread_count' => fn ($q) => $q->unreadForBuyer()]);
|
||||
->withCount('messages');
|
||||
|
||||
// Apply filters
|
||||
$query = match ($filter) {
|
||||
@@ -54,6 +54,7 @@ class InboxController extends Controller
|
||||
];
|
||||
|
||||
return view('buyer.crm.inbox.index', compact(
|
||||
'business',
|
||||
'threads',
|
||||
'filter',
|
||||
'search',
|
||||
@@ -62,9 +63,8 @@ class InboxController extends Controller
|
||||
));
|
||||
}
|
||||
|
||||
public function show(CrmThread $thread)
|
||||
public function show(Business $business, CrmThread $thread)
|
||||
{
|
||||
$business = Auth::user()->business;
|
||||
|
||||
// Verify thread belongs to this buyer
|
||||
if ($thread->buyer_business_id !== $business->id) {
|
||||
@@ -84,9 +84,8 @@ class InboxController extends Controller
|
||||
return view('buyer.crm.inbox.show', compact('thread'));
|
||||
}
|
||||
|
||||
public function compose(Request $request)
|
||||
public function compose(Request $request, Business $business)
|
||||
{
|
||||
$business = Auth::user()->business;
|
||||
|
||||
// Get brands the buyer has ordered from or can message
|
||||
$brands = \App\Models\Brand::whereHas('products.orderItems.order', function ($q) use ($business) {
|
||||
@@ -107,7 +106,7 @@ class InboxController extends Controller
|
||||
));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'brand_id' => 'required|exists:brands,id',
|
||||
@@ -117,7 +116,6 @@ class InboxController extends Controller
|
||||
'quote_id' => 'nullable|exists:crm_quotes,id',
|
||||
]);
|
||||
|
||||
$business = Auth::user()->business;
|
||||
$user = Auth::user();
|
||||
|
||||
// Create thread
|
||||
@@ -143,9 +141,8 @@ class InboxController extends Controller
|
||||
->with('success', 'Message sent successfully.');
|
||||
}
|
||||
|
||||
public function star(CrmThread $thread)
|
||||
public function star(Business $business, CrmThread $thread)
|
||||
{
|
||||
$business = Auth::user()->business;
|
||||
$user = Auth::user();
|
||||
|
||||
if ($thread->buyer_business_id !== $business->id) {
|
||||
@@ -157,9 +154,8 @@ class InboxController extends Controller
|
||||
return back()->with('success', $thread->isStarredByBuyer($user->id) ? 'Conversation starred.' : 'Star removed.');
|
||||
}
|
||||
|
||||
public function archive(CrmThread $thread)
|
||||
public function archive(Business $business, CrmThread $thread)
|
||||
{
|
||||
$business = Auth::user()->business;
|
||||
$user = Auth::user();
|
||||
|
||||
if ($thread->buyer_business_id !== $business->id) {
|
||||
@@ -172,9 +168,8 @@ class InboxController extends Controller
|
||||
->with('success', 'Conversation archived.');
|
||||
}
|
||||
|
||||
public function unarchive(CrmThread $thread)
|
||||
public function unarchive(Business $business, CrmThread $thread)
|
||||
{
|
||||
$business = Auth::user()->business;
|
||||
$user = Auth::user();
|
||||
|
||||
if ($thread->buyer_business_id !== $business->id) {
|
||||
@@ -186,9 +181,8 @@ class InboxController extends Controller
|
||||
return back()->with('success', 'Conversation restored.');
|
||||
}
|
||||
|
||||
public function markAllRead()
|
||||
public function markAllRead(Business $business)
|
||||
{
|
||||
$business = Auth::user()->business;
|
||||
|
||||
CrmThread::forBuyerBusiness($business->id)
|
||||
->hasUnreadForBuyer()
|
||||
|
||||
45
app/Http/Controllers/Buyer/ProductController.php
Normal file
45
app/Http/Controllers/Buyer/ProductController.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Buyer;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Product;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class ProductController extends Controller
|
||||
{
|
||||
/**
|
||||
* Get quick view data for a product (AJAX endpoint).
|
||||
*/
|
||||
public function quickView(Product $product): JsonResponse
|
||||
{
|
||||
// Only return active products
|
||||
if (! $product->is_active) {
|
||||
return response()->json(['error' => 'Product not found'], 404);
|
||||
}
|
||||
|
||||
// Get the product's brand
|
||||
$product->load('brand:id,name,slug');
|
||||
|
||||
return response()->json([
|
||||
'id' => $product->id,
|
||||
'hashid' => $product->hashid,
|
||||
'name' => $product->name,
|
||||
'sku' => $product->sku,
|
||||
'description' => $product->short_description ?? $product->description,
|
||||
'price' => $product->wholesale_price ?? 0,
|
||||
'price_unit' => $product->price_unit,
|
||||
'thc_percentage' => $product->thc_percentage,
|
||||
'cbd_percentage' => $product->cbd_percentage,
|
||||
'in_stock' => $product->isInStock(),
|
||||
'available_quantity' => $product->quantity_on_hand,
|
||||
'image_url' => $product->getImageUrl('medium'),
|
||||
'brand_name' => $product->brand?->name,
|
||||
'brand_slug' => $product->brand?->slug,
|
||||
'brand_url' => $product->brand ? route('buyer.brands.show', $product->brand->slug) : null,
|
||||
'url' => $product->brand ? route('buyer.brands.products.show', [$product->brand->slug, $product->hashid]) : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
129
app/Http/Controllers/Buyer/SearchController.php
Normal file
129
app/Http/Controllers/Buyer/SearchController.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Buyer;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Brand;
|
||||
use App\Models\Product;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Buyer Search Controller
|
||||
*
|
||||
* Provides search autocomplete endpoints for the marketplace header.
|
||||
*/
|
||||
class SearchController extends Controller
|
||||
{
|
||||
/**
|
||||
* Search autocomplete for products and brands.
|
||||
*
|
||||
* GET /b/search/autocomplete?q=...
|
||||
*
|
||||
* Returns products and brands matching the query for dropdown suggestions.
|
||||
*/
|
||||
public function autocomplete(Request $request): JsonResponse
|
||||
{
|
||||
$query = trim($request->input('q', ''));
|
||||
|
||||
if (strlen($query) < 2) {
|
||||
return response()->json(['products' => [], 'brands' => []]);
|
||||
}
|
||||
|
||||
// Search products (limit 8)
|
||||
$products = Product::query()
|
||||
->where('is_active', true)
|
||||
->whereHas('brand', fn ($q) => $q->where('is_active', true))
|
||||
->with('brand:id,name,slug')
|
||||
->where(function ($q) use ($query) {
|
||||
$q->where('name', 'ILIKE', "%{$query}%")
|
||||
->orWhere('sku', 'ILIKE', "%{$query}%")
|
||||
->orWhereHas('brand', fn ($b) => $b->where('name', 'ILIKE', "%{$query}%"));
|
||||
})
|
||||
->orderByRaw("CASE WHEN name ILIKE ? THEN 0 ELSE 1 END", ["{$query}%"])
|
||||
->orderBy('name')
|
||||
->limit(8)
|
||||
->get(['id', 'brand_id', 'name', 'sku', 'wholesale_price', 'image_path']);
|
||||
|
||||
// Search brands (limit 4)
|
||||
$brands = Brand::query()
|
||||
->where('is_active', true)
|
||||
->where(function ($q) use ($query) {
|
||||
$q->where('name', 'ILIKE', "%{$query}%")
|
||||
->orWhere('description', 'ILIKE', "%{$query}%");
|
||||
})
|
||||
->withCount('products')
|
||||
->orderByRaw("CASE WHEN name ILIKE ? THEN 0 ELSE 1 END", ["{$query}%"])
|
||||
->orderBy('name')
|
||||
->limit(4)
|
||||
->get(['id', 'name', 'slug', 'logo_path']);
|
||||
|
||||
return response()->json([
|
||||
'products' => $products->map(fn ($p) => [
|
||||
'id' => $p->id,
|
||||
'hashid' => $p->hashid,
|
||||
'name' => $p->name,
|
||||
'sku' => $p->sku,
|
||||
'price' => $p->wholesale_price ?? 0,
|
||||
'image_url' => $p->getImageUrl('thumb'),
|
||||
'brand_name' => $p->brand?->name,
|
||||
'brand_slug' => $p->brand?->slug,
|
||||
'url' => route('buyer.brands.products.show', [$p->brand?->slug, $p->hashid]),
|
||||
]),
|
||||
'brands' => $brands->map(fn ($b) => [
|
||||
'id' => $b->id,
|
||||
'name' => $b->name,
|
||||
'slug' => $b->slug,
|
||||
'logo_url' => $b->getLogoUrl('thumb'),
|
||||
'products_count' => $b->products_count,
|
||||
'url' => route('buyer.brands.show', $b->slug),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search suggestions based on popular searches.
|
||||
*
|
||||
* GET /b/search/suggestions
|
||||
*
|
||||
* Returns popular search terms and trending products.
|
||||
*/
|
||||
public function suggestions(): JsonResponse
|
||||
{
|
||||
// Popular search terms (could be tracked and stored, for now use static list)
|
||||
$popularTerms = [
|
||||
'gummies',
|
||||
'vape',
|
||||
'flower',
|
||||
'indica',
|
||||
'sativa',
|
||||
'edibles',
|
||||
'pre-roll',
|
||||
'concentrate',
|
||||
];
|
||||
|
||||
// Trending products (recently added or best sellers)
|
||||
$trending = Product::query()
|
||||
->where('is_active', true)
|
||||
->whereHas('brand', fn ($q) => $q->where('is_active', true))
|
||||
->with('brand:id,name,slug')
|
||||
->orderBy('created_at', 'desc')
|
||||
->limit(4)
|
||||
->get(['id', 'brand_id', 'name', 'image_path']);
|
||||
|
||||
return response()->json([
|
||||
'terms' => $popularTerms,
|
||||
'trending' => $trending->map(fn ($p) => [
|
||||
'id' => $p->id,
|
||||
'hashid' => $p->hashid,
|
||||
'name' => $p->name,
|
||||
'image_url' => $p->getImageUrl('thumb'),
|
||||
'brand_name' => $p->brand?->name,
|
||||
'brand_slug' => $p->brand?->slug,
|
||||
'url' => route('buyer.brands.products.show', [$p->brand?->slug, $p->hashid]),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,39 @@ use Intervention\Image\ImageManager;
|
||||
*/
|
||||
class ImageController extends Controller
|
||||
{
|
||||
/**
|
||||
* Cache duration for images (1 year in seconds)
|
||||
*/
|
||||
private const CACHE_TTL = 31536000;
|
||||
|
||||
/**
|
||||
* Return a cached response for an image
|
||||
*/
|
||||
private function cachedResponse(string $contents, string $mimeType, ?string $etag = null): \Illuminate\Http\Response
|
||||
{
|
||||
$response = response($contents)
|
||||
->header('Content-Type', $mimeType)
|
||||
->header('Cache-Control', 'public, max-age='.self::CACHE_TTL.', immutable')
|
||||
->header('Expires', gmdate('D, d M Y H:i:s', time() + self::CACHE_TTL).' GMT');
|
||||
|
||||
if ($etag) {
|
||||
$response->header('ETag', '"'.$etag.'"');
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a cached file response
|
||||
*/
|
||||
private function cachedFileResponse(string $path): \Symfony\Component\HttpFoundation\BinaryFileResponse
|
||||
{
|
||||
return response()->file($path, [
|
||||
'Cache-Control' => 'public, max-age='.self::CACHE_TTL.', immutable',
|
||||
'Expires' => gmdate('D, d M Y H:i:s', time() + self::CACHE_TTL).' GMT',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve a brand logo at a specific size
|
||||
* URL: /images/brand-logo/{brand}/{width?}
|
||||
@@ -67,8 +100,9 @@ class ImageController extends Controller
|
||||
if (! $width) {
|
||||
$contents = Storage::get($brand->logo_path);
|
||||
$mimeType = Storage::mimeType($brand->logo_path);
|
||||
$etag = md5($brand->logo_path.$brand->updated_at);
|
||||
|
||||
return response($contents)->header('Content-Type', $mimeType);
|
||||
return $this->cachedResponse($contents, $mimeType, $etag);
|
||||
}
|
||||
|
||||
// Map common widths to pre-generated sizes (retina-optimized)
|
||||
@@ -104,7 +138,7 @@ class ImageController extends Controller
|
||||
|
||||
$path = storage_path('app/private/'.$thumbnailPath);
|
||||
|
||||
return response()->file($path);
|
||||
return $this->cachedFileResponse($path);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -121,8 +155,9 @@ class ImageController extends Controller
|
||||
if (! $width) {
|
||||
$contents = Storage::get($brand->banner_path);
|
||||
$mimeType = Storage::mimeType($brand->banner_path);
|
||||
$etag = md5($brand->banner_path.$brand->updated_at);
|
||||
|
||||
return response($contents)->header('Content-Type', $mimeType);
|
||||
return $this->cachedResponse($contents, $mimeType, $etag);
|
||||
}
|
||||
|
||||
// Map common widths to pre-generated sizes (retina-optimized)
|
||||
@@ -155,7 +190,7 @@ class ImageController extends Controller
|
||||
|
||||
$path = storage_path('app/private/'.$thumbnailPath);
|
||||
|
||||
return response()->file($path);
|
||||
return $this->cachedFileResponse($path);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -172,8 +207,9 @@ class ImageController extends Controller
|
||||
if (! $width) {
|
||||
$contents = Storage::get($product->image_path);
|
||||
$mimeType = Storage::mimeType($product->image_path);
|
||||
$etag = md5($product->image_path.$product->updated_at);
|
||||
|
||||
return response($contents)->header('Content-Type', $mimeType);
|
||||
return $this->cachedResponse($contents, $mimeType, $etag);
|
||||
}
|
||||
|
||||
// Check if cached dynamic thumbnail exists in local storage
|
||||
@@ -202,6 +238,54 @@ class ImageController extends Controller
|
||||
|
||||
$path = storage_path('app/private/'.$thumbnailPath);
|
||||
|
||||
return response()->file($path);
|
||||
return $this->cachedFileResponse($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve a product image from the product_images table by ID
|
||||
* URL: /images/product-image/{productImage}/{width?}
|
||||
*/
|
||||
public function productImageById(\App\Models\ProductImage $productImage, ?int $width = null)
|
||||
{
|
||||
if (! $productImage->path || ! Storage::exists($productImage->path)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// If no width specified, return original from storage
|
||||
if (! $width) {
|
||||
$contents = Storage::get($productImage->path);
|
||||
$mimeType = Storage::mimeType($productImage->path);
|
||||
$etag = md5($productImage->path.$productImage->updated_at);
|
||||
|
||||
return $this->cachedResponse($contents, $mimeType, $etag);
|
||||
}
|
||||
|
||||
// Check if cached dynamic thumbnail exists in local storage
|
||||
$ext = pathinfo($productImage->path, PATHINFO_EXTENSION);
|
||||
$thumbnailName = 'pi-'.$productImage->id.'-'.$width.'w.'.$ext;
|
||||
$thumbnailPath = 'products/cache/'.$thumbnailName;
|
||||
|
||||
if (! Storage::disk('local')->exists($thumbnailPath)) {
|
||||
// Fetch original from default storage disk (MinIO)
|
||||
$originalContents = Storage::get($productImage->path);
|
||||
|
||||
// Generate thumbnail on-the-fly
|
||||
$manager = new ImageManager(new Driver);
|
||||
$image = $manager->read($originalContents);
|
||||
$image->scale(width: $width);
|
||||
|
||||
// Cache the thumbnail locally for performance
|
||||
if (! Storage::disk('local')->exists('products/cache')) {
|
||||
Storage::disk('local')->makeDirectory('products/cache');
|
||||
}
|
||||
|
||||
// Save as PNG or JPEG based on original format
|
||||
$encoded = $ext === 'png' ? $image->toPng() : $image->toJpeg(quality: 90);
|
||||
Storage::disk('local')->put($thumbnailPath, $encoded);
|
||||
}
|
||||
|
||||
$path = storage_path('app/private/'.$thumbnailPath);
|
||||
|
||||
return $this->cachedFileResponse($path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,19 +4,29 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Brand;
|
||||
use App\Models\Product;
|
||||
use App\Models\ProductCategory;
|
||||
use App\Models\Strain;
|
||||
use App\Services\RecentlyViewedService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class MarketplaceController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected RecentlyViewedService $recentlyViewed
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Display marketplace browse page
|
||||
* Display marketplace browse page (Amazon/Shopify style)
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$business = auth()->user()->businesses->first();
|
||||
$hasFilters = $request->hasAny(['search', 'brand_id', 'strain_type', 'price_min', 'price_max', 'in_stock', 'category_id']);
|
||||
|
||||
// Start with active products only
|
||||
$query = Product::query()
|
||||
->with(['brand', 'strain'])
|
||||
->with(['brand:id,name,slug,hashid,logo_path,updated_at', 'strain:id,name,type', 'category:id,name,slug'])
|
||||
->active();
|
||||
|
||||
// Search filter (name, SKU, description)
|
||||
@@ -28,15 +38,24 @@ class MarketplaceController extends Controller
|
||||
});
|
||||
}
|
||||
|
||||
// Brand filter
|
||||
if ($brandId = $request->input('brand_id')) {
|
||||
$query->where('brand_id', $brandId);
|
||||
// Brand filter (supports multiple)
|
||||
if ($brandIds = $request->input('brand_id')) {
|
||||
$brandIds = is_array($brandIds) ? $brandIds : [$brandIds];
|
||||
$query->whereIn('brand_id', $brandIds);
|
||||
}
|
||||
|
||||
// Strain type filter
|
||||
// Category filter (uses category_id foreign key)
|
||||
if ($categoryId = $request->input('category_id')) {
|
||||
$query->where('category_id', $categoryId);
|
||||
}
|
||||
|
||||
// Strain type filter - use join instead of whereHas for performance
|
||||
if ($strainType = $request->input('strain_type')) {
|
||||
$query->whereHas('strain', function ($q) use ($strainType) {
|
||||
$q->where('type', $strainType);
|
||||
$query->whereExists(function ($q) use ($strainType) {
|
||||
$q->select(DB::raw(1))
|
||||
->from('strains')
|
||||
->whereColumn('strains.id', 'products.strain_id')
|
||||
->where('strains.type', $strainType);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -64,23 +83,121 @@ class MarketplaceController extends Controller
|
||||
default => $query->latest(),
|
||||
};
|
||||
|
||||
// View mode (grid/list)
|
||||
$viewMode = $request->input('view', 'grid');
|
||||
|
||||
// Paginate results
|
||||
$products = $query->paginate(12)->withQueryString();
|
||||
$perPage = $viewMode === 'list' ? 10 : 12;
|
||||
$products = $query->paginate($perPage)->withQueryString();
|
||||
|
||||
// Get all active brands for filters
|
||||
$brands = Brand::active()->orderBy('name')->get();
|
||||
// Cache brands and categories for 5 minutes (used frequently, rarely change)
|
||||
$brands = cache()->remember('marketplace:brands', 300, function () {
|
||||
return Brand::query()
|
||||
->active()
|
||||
->whereExists(function ($q) {
|
||||
$q->select(DB::raw(1))
|
||||
->from('products')
|
||||
->whereColumn('products.brand_id', 'brands.id')
|
||||
->where('products.is_active', true);
|
||||
})
|
||||
->withCount(['products' => fn ($q) => $q->active()])
|
||||
->orderBy('name')
|
||||
->get();
|
||||
});
|
||||
|
||||
// Get featured products for carousel (exclude from main results if in first page)
|
||||
$featuredProducts = Product::query()
|
||||
->with(['brand', 'strain'])
|
||||
->featured()
|
||||
->inStock()
|
||||
->limit(3)
|
||||
->get();
|
||||
// Cache categories for 5 minutes
|
||||
$categories = cache()->remember('marketplace:categories', 300, function () {
|
||||
return ProductCategory::query()
|
||||
->whereNull('parent_id')
|
||||
->where('is_active', true)
|
||||
->whereExists(function ($q) {
|
||||
$q->select(DB::raw(1))
|
||||
->from('products')
|
||||
->whereColumn('products.category_id', 'product_categories.id')
|
||||
->where('products.is_active', true);
|
||||
})
|
||||
->withCount(['products' => fn ($q) => $q->active()])
|
||||
->orderByDesc('products_count')
|
||||
->get();
|
||||
});
|
||||
|
||||
$business = auth()->user()->businesses->first();
|
||||
// Only load extra sections if not filtering (homepage view)
|
||||
$featuredProducts = collect();
|
||||
$topBrands = collect();
|
||||
$newArrivals = collect();
|
||||
$trending = collect();
|
||||
$recentlyViewed = collect();
|
||||
|
||||
return view('buyer.marketplace.index', compact('products', 'brands', 'featuredProducts', 'business'));
|
||||
if (! $hasFilters) {
|
||||
// Featured products for hero carousel
|
||||
$featuredProducts = Product::query()
|
||||
->with(['brand:id,name,slug,hashid,logo_path,updated_at', 'strain:id,name,type'])
|
||||
->featured()
|
||||
->inStock()
|
||||
->limit(5)
|
||||
->get();
|
||||
|
||||
// Top brands - reuse cached brands
|
||||
$topBrands = $brands->sortByDesc('products_count')->take(6);
|
||||
|
||||
// New arrivals (products created in last 14 days)
|
||||
$newArrivals = Product::query()
|
||||
->with(['brand:id,name,slug,hashid,logo_path,updated_at', 'strain:id,name,type'])
|
||||
->active()
|
||||
->inStock()
|
||||
->where('created_at', '>=', now()->subDays(14))
|
||||
->orderByDesc('created_at')
|
||||
->limit(8)
|
||||
->get();
|
||||
|
||||
// Trending products - cache for 10 minutes
|
||||
$trending = cache()->remember('marketplace:trending', 600, function () {
|
||||
$trendingIds = DB::table('order_items')
|
||||
->select('product_id', DB::raw('SUM(quantity) as total_sold'))
|
||||
->where('created_at', '>=', now()->subDays(30))
|
||||
->groupBy('product_id')
|
||||
->orderByDesc('total_sold')
|
||||
->limit(8)
|
||||
->pluck('product_id');
|
||||
|
||||
if ($trendingIds->isEmpty()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return Product::with(['brand:id,name,slug,hashid,logo_path,updated_at', 'strain:id,name,type'])
|
||||
->whereIn('id', $trendingIds)
|
||||
->active()
|
||||
->get()
|
||||
->sortBy(fn ($p) => array_search($p->id, $trendingIds->toArray()));
|
||||
});
|
||||
|
||||
// Recently viewed products
|
||||
$recentlyViewed = $this->recentlyViewed->getProducts(6);
|
||||
}
|
||||
|
||||
// Active filters for pills display
|
||||
$activeFilters = collect([
|
||||
'search' => $request->input('search'),
|
||||
'brand_id' => $request->input('brand_id'),
|
||||
'category_id' => $request->input('category_id'),
|
||||
'strain_type' => $request->input('strain_type'),
|
||||
'in_stock' => $request->input('in_stock'),
|
||||
])->filter();
|
||||
|
||||
return view('buyer.marketplace.index', compact(
|
||||
'products',
|
||||
'brands',
|
||||
'categories',
|
||||
'featuredProducts',
|
||||
'topBrands',
|
||||
'newArrivals',
|
||||
'trending',
|
||||
'recentlyViewed',
|
||||
'business',
|
||||
'viewMode',
|
||||
'activeFilters',
|
||||
'hasFilters'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,19 +211,64 @@ class MarketplaceController extends Controller
|
||||
/**
|
||||
* Display all brands directory
|
||||
*/
|
||||
public function brands()
|
||||
public function brands(Request $request)
|
||||
{
|
||||
$brands = Brand::query()
|
||||
->active()
|
||||
->withCount(['products' => function ($query) {
|
||||
$query->active();
|
||||
}])
|
||||
->orderBy('name')
|
||||
->get();
|
||||
$search = $request->input('search');
|
||||
$sort = $request->input('sort', 'name');
|
||||
|
||||
// Only cache if no search (search results shouldn't be cached)
|
||||
$cacheKey = $search ? null : "marketplace:brands_directory:{$sort}";
|
||||
|
||||
$brands = $cacheKey
|
||||
? cache()->remember($cacheKey, 300, fn () => $this->getBrandsQuery($search, $sort))
|
||||
: $this->getBrandsQuery($search, $sort);
|
||||
|
||||
// Group brands alphabetically for index navigation
|
||||
$alphabetGroups = $brands->groupBy(fn ($b) => strtoupper(substr($b->name, 0, 1)));
|
||||
|
||||
// Featured brands (first 4 with most products)
|
||||
$featuredBrands = $brands->sortByDesc('products_count')->take(4);
|
||||
|
||||
$business = auth()->user()->businesses->first();
|
||||
|
||||
return view('buyer.marketplace.brands', compact('brands', 'business'));
|
||||
return view('buyer.marketplace.brands', compact('brands', 'alphabetGroups', 'featuredBrands', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to build brands query for directory
|
||||
*/
|
||||
private function getBrandsQuery(?string $search, string $sort)
|
||||
{
|
||||
$query = Brand::query()
|
||||
->select(['id', 'name', 'slug', 'hashid', 'tagline', 'logo_path', 'updated_at'])
|
||||
->active()
|
||||
// Filter to only brands with active products using EXISTS (faster than having())
|
||||
->whereExists(function ($q) {
|
||||
$q->select(DB::raw(1))
|
||||
->from('products')
|
||||
->whereColumn('products.brand_id', 'brands.id')
|
||||
->where('products.is_active', true);
|
||||
})
|
||||
->withCount(['products' => fn ($q) => $q->active()]);
|
||||
|
||||
// Search filter
|
||||
if ($search) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'ILIKE', "%{$search}%")
|
||||
->orWhere('tagline', 'ILIKE', "%{$search}%")
|
||||
->orWhere('description', 'ILIKE', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// Sorting
|
||||
match ($sort) {
|
||||
'name_desc' => $query->orderByDesc('name'),
|
||||
'products' => $query->orderByDesc('products_count'),
|
||||
'newest' => $query->orderByDesc('created_at'),
|
||||
default => $query->orderBy('name'),
|
||||
};
|
||||
|
||||
return $query->get();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,27 +286,30 @@ class MarketplaceController extends Controller
|
||||
*/
|
||||
public function showProduct($brandSlug, $productSlug)
|
||||
{
|
||||
// Find brand by slug
|
||||
// Find brand by slug - minimal columns
|
||||
$brand = Brand::query()
|
||||
->select(['id', 'name', 'slug', 'hashid', 'logo_path', 'banner_path', 'tagline', 'description', 'updated_at'])
|
||||
->where('slug', $brandSlug)
|
||||
->active()
|
||||
->firstOrFail();
|
||||
|
||||
// Find product by slug within this brand
|
||||
// Find product by hashid, slug, or numeric ID within this brand
|
||||
$product = Product::query()
|
||||
->with([
|
||||
'brand',
|
||||
'strain',
|
||||
'brand:id,name,slug,hashid,logo_path,updated_at',
|
||||
'strain:id,name,type',
|
||||
// Only load batches if needed - limit to recent ones
|
||||
'availableBatches' => function ($query) {
|
||||
$query->with(['coaFiles'])
|
||||
->orderBy('production_date', 'desc')
|
||||
->orderBy('created_at', 'desc');
|
||||
$query->select(['id', 'product_id', 'batch_number', 'production_date', 'quantity_available'])
|
||||
->with(['coaFiles:id,batch_id,file_path,file_name'])
|
||||
->orderByDesc('production_date')
|
||||
->limit(5);
|
||||
},
|
||||
])
|
||||
->where('brand_id', $brand->id)
|
||||
->where(function ($query) use ($productSlug) {
|
||||
$query->where('slug', $productSlug);
|
||||
// Only try ID lookup if the value is numeric
|
||||
$query->where('hashid', $productSlug)
|
||||
->orWhere('slug', $productSlug);
|
||||
if (is_numeric($productSlug)) {
|
||||
$query->orWhere('id', $productSlug);
|
||||
}
|
||||
@@ -152,9 +317,12 @@ class MarketplaceController extends Controller
|
||||
->active()
|
||||
->firstOrFail();
|
||||
|
||||
// Get related products from same brand
|
||||
// Record this view for recently viewed products (async-friendly)
|
||||
$this->recentlyViewed->recordView($product->id);
|
||||
|
||||
// Get related products from same brand - minimal eager loading
|
||||
$relatedProducts = Product::query()
|
||||
->with(['brand', 'strain'])
|
||||
->with(['brand:id,name,slug,hashid,logo_path,updated_at', 'strain:id,name,type'])
|
||||
->where('brand_id', $product->brand_id)
|
||||
->where('id', '!=', $product->id)
|
||||
->active()
|
||||
@@ -162,9 +330,69 @@ class MarketplaceController extends Controller
|
||||
->limit(4)
|
||||
->get();
|
||||
|
||||
// Get recently viewed products (excluding current product)
|
||||
$recentlyViewed = $this->recentlyViewed->getProducts(6, $product->id);
|
||||
|
||||
$business = auth()->user()->businesses->first();
|
||||
|
||||
return view('buyer.marketplace.product', compact('product', 'relatedProducts', 'brand', 'business'));
|
||||
return view('buyer.marketplace.product', compact('product', 'relatedProducts', 'recentlyViewed', 'brand', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display deals/promotions page for buyers
|
||||
*/
|
||||
public function deals()
|
||||
{
|
||||
// Get all active promotions with their brands and products
|
||||
$activePromos = \App\Models\Promotion::query()
|
||||
->with([
|
||||
'brand:id,name,slug,hashid,logo_path,updated_at',
|
||||
'products' => fn ($q) => $q->with(['brand:id,name,slug,hashid,logo_path,updated_at'])->active()->inStock(),
|
||||
])
|
||||
->active()
|
||||
->orderByDesc('discount_value')
|
||||
->get();
|
||||
|
||||
// Group by type for display sections
|
||||
$percentageDeals = $activePromos->where('type', 'percentage');
|
||||
$bogoDeals = $activePromos->where('type', 'bogo');
|
||||
$fixedDeals = $activePromos->where('type', 'bundle');
|
||||
$priceOverrides = $activePromos->where('type', 'price_override');
|
||||
|
||||
// Get all products that are on any active promotion
|
||||
$dealProducts = Product::query()
|
||||
->with(['brand:id,name,slug,hashid,logo_path,updated_at', 'strain:id,name,type'])
|
||||
->whereHas('promotions', fn ($q) => $q->active())
|
||||
->active()
|
||||
->inStock()
|
||||
->limit(16)
|
||||
->get();
|
||||
|
||||
// Get brands with active deals
|
||||
$brandsWithDeals = Brand::query()
|
||||
->select(['id', 'name', 'slug', 'hashid', 'logo_path', 'updated_at'])
|
||||
->whereHas('promotions', fn ($q) => $q->active())
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Stats for the header
|
||||
$stats = [
|
||||
'total_deals' => $activePromos->count(),
|
||||
'percentage_deals' => $percentageDeals->count(),
|
||||
'bogo_deals' => $bogoDeals->count(),
|
||||
'bundle_deals' => $fixedDeals->count() + $priceOverrides->count(),
|
||||
];
|
||||
|
||||
return view('buyer.marketplace.deals', compact(
|
||||
'activePromos',
|
||||
'dealProducts',
|
||||
'percentageDeals',
|
||||
'bogoDeals',
|
||||
'fixedDeals',
|
||||
'priceOverrides',
|
||||
'brandsWithDeals',
|
||||
'stats'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -172,27 +400,30 @@ class MarketplaceController extends Controller
|
||||
*/
|
||||
public function showBrand($brandSlug)
|
||||
{
|
||||
// Find brand by slug
|
||||
// Find brand by slug with minimal columns
|
||||
$brand = Brand::query()
|
||||
->where('slug', $brandSlug)
|
||||
->active()
|
||||
->firstOrFail();
|
||||
|
||||
// Get featured products from this brand
|
||||
// Optimized: Use simple inStock scope instead of expensive whereHas on batches
|
||||
// The inStock scope should check inventory_mode or quantity_on_hand
|
||||
$featuredProducts = Product::query()
|
||||
->with(['strain'])
|
||||
->with(['strain:id,name,type', 'brand:id,name,slug,hashid,logo_path,updated_at'])
|
||||
->where('brand_id', $brand->id)
|
||||
->active()
|
||||
->featured()
|
||||
->inStock()
|
||||
->limit(3)
|
||||
->get();
|
||||
|
||||
// Get all products from this brand
|
||||
// Get products - use simpler inStock check
|
||||
$products = Product::query()
|
||||
->with(['strain'])
|
||||
->with(['strain:id,name,type', 'brand:id,name,slug,hashid,logo_path,updated_at'])
|
||||
->where('brand_id', $brand->id)
|
||||
->active()
|
||||
->orderBy('is_featured', 'desc')
|
||||
->inStock()
|
||||
->orderByDesc('is_featured')
|
||||
->orderBy('name')
|
||||
->paginate(20);
|
||||
|
||||
|
||||
@@ -97,6 +97,135 @@ class OrderController extends Controller
|
||||
return view('seller.orders.index', compact('orders', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for creating a new order (seller-initiated).
|
||||
*/
|
||||
public function create(\App\Models\Business $business): View
|
||||
{
|
||||
// Get all buyer businesses for the customer dropdown
|
||||
$buyers = \App\Models\Business::where('is_active', true)
|
||||
->whereIn('business_type', ['buyer', 'both'])
|
||||
->with(['locations' => function ($query) {
|
||||
$query->where('is_active', true)->orderBy('is_primary', 'desc')->orderBy('name');
|
||||
}])
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Get recently ordered products (last 30 days, top 10 most common)
|
||||
$recentProducts = \App\Models\Product::forBusiness($business)
|
||||
->whereHas('orderItems', function ($query) {
|
||||
$query->where('created_at', '>=', now()->subDays(30));
|
||||
})
|
||||
->with(['brand', 'images'])
|
||||
->withCount(['orderItems' => function ($query) {
|
||||
$query->where('created_at', '>=', now()->subDays(30));
|
||||
}])
|
||||
->orderByDesc('order_items_count')
|
||||
->take(10)
|
||||
->get()
|
||||
->map(function ($product) use ($business) {
|
||||
// Calculate inventory from InventoryItem model
|
||||
$totalOnHand = $product->inventoryItems()
|
||||
->where('business_id', $business->id)
|
||||
->sum('quantity_on_hand');
|
||||
$totalAllocated = $product->inventoryItems()
|
||||
->where('business_id', $business->id)
|
||||
->sum('quantity_allocated');
|
||||
|
||||
return [
|
||||
'id' => $product->id,
|
||||
'name' => $product->name,
|
||||
'sku' => $product->sku,
|
||||
'brand_name' => $product->brand?->name,
|
||||
'wholesale_price' => $product->wholesale_price,
|
||||
'quantity_available' => max(0, $totalOnHand - $totalAllocated),
|
||||
'type' => $product->type,
|
||||
];
|
||||
});
|
||||
|
||||
return view('seller.orders.create', compact('business', 'buyers', 'recentProducts'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created order (seller-initiated).
|
||||
*/
|
||||
public function store(\App\Models\Business $business, Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'buyer_business_id' => 'required|exists:businesses,id',
|
||||
'location_id' => 'nullable|exists:locations,id',
|
||||
'contact_id' => 'nullable|exists:contacts,id',
|
||||
'payment_terms' => 'required|in:cod,net_15,net_30,net_60,net_90',
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.product_id' => 'required|exists:products,id',
|
||||
'items.*.quantity' => 'required|integer|min:1',
|
||||
'items.*.unit_price' => 'required|numeric|min:0',
|
||||
'items.*.discount_amount' => 'nullable|numeric|min:0',
|
||||
'items.*.discount_type' => 'nullable|in:fixed,percent',
|
||||
'items.*.notes' => 'nullable|string|max:500',
|
||||
'items.*.batch_id' => 'nullable|exists:batches,id',
|
||||
]);
|
||||
|
||||
try {
|
||||
// Create the order
|
||||
$order = Order::create([
|
||||
'business_id' => $validated['buyer_business_id'],
|
||||
'location_id' => $validated['location_id'] ?? null,
|
||||
'contact_id' => $validated['contact_id'] ?? null,
|
||||
'user_id' => auth()->id(),
|
||||
'status' => 'new',
|
||||
'created_by' => 'seller',
|
||||
'payment_terms' => $validated['payment_terms'],
|
||||
'notes' => $validated['notes'] ?? null,
|
||||
]);
|
||||
|
||||
// Add line items
|
||||
$subtotal = 0;
|
||||
foreach ($validated['items'] as $item) {
|
||||
$product = \App\Models\Product::findOrFail($item['product_id']);
|
||||
|
||||
$lineSubtotal = $item['quantity'] * $item['unit_price'];
|
||||
$discountAmount = 0;
|
||||
|
||||
if (! empty($item['discount_amount']) && $item['discount_amount'] > 0) {
|
||||
if (($item['discount_type'] ?? 'percent') === 'percent') {
|
||||
$discountAmount = $lineSubtotal * ($item['discount_amount'] / 100);
|
||||
} else {
|
||||
$discountAmount = $item['discount_amount'];
|
||||
}
|
||||
}
|
||||
|
||||
$lineTotal = $lineSubtotal - $discountAmount;
|
||||
$subtotal += $lineTotal;
|
||||
|
||||
$order->items()->create([
|
||||
'product_id' => $item['product_id'],
|
||||
'batch_id' => $item['batch_id'] ?? null,
|
||||
'quantity' => $item['quantity'],
|
||||
'price' => $item['unit_price'],
|
||||
'discount_amount' => $discountAmount,
|
||||
'total' => $lineTotal,
|
||||
'notes' => $item['notes'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
// Update order totals
|
||||
$order->update([
|
||||
'subtotal' => $subtotal,
|
||||
'total' => $subtotal, // Tax can be added later
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('success', 'Order created successfully!');
|
||||
} catch (\Exception $e) {
|
||||
return back()
|
||||
->withInput()
|
||||
->with('error', 'Failed to create order: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display order detail with workorder/picking ticket functionality.
|
||||
*/
|
||||
@@ -213,6 +342,41 @@ class OrderController extends Controller
|
||||
return back()->with('success', "Order {$order->order_number} has been cancelled.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Update item comment for an order line item.
|
||||
*/
|
||||
public function updateItemComment(\App\Models\Business $business, Order $order, \App\Models\OrderItem $orderItem, Request $request): RedirectResponse
|
||||
{
|
||||
// Verify the item belongs to this order
|
||||
if ($orderItem->order_id !== $order->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'item_comment' => 'nullable|string|max:2000',
|
||||
]);
|
||||
|
||||
$orderItem->update([
|
||||
'item_comment' => $validated['item_comment'],
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Item comment updated.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle ping pong mode for an order.
|
||||
*/
|
||||
public function togglePingPong(\App\Models\Business $business, Order $order): RedirectResponse
|
||||
{
|
||||
$order->update([
|
||||
'is_ping_pong' => ! $order->is_ping_pong,
|
||||
]);
|
||||
|
||||
$status = $order->is_ping_pong ? 'enabled' : 'disabled';
|
||||
|
||||
return back()->with('success', "Ping Pong flow {$status} for this order.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve order for delivery (after buyer selects delivery method).
|
||||
*/
|
||||
|
||||
@@ -55,6 +55,7 @@ class BrandController extends Controller
|
||||
'is_active' => $brand->is_active,
|
||||
'is_public' => $brand->is_public,
|
||||
'is_featured' => $brand->is_featured,
|
||||
'is_cannaiq_connected' => $brand->isCannaiqConnected(),
|
||||
'products_count' => $brand->products_count ?? 0,
|
||||
'updated_at' => $brand->updated_at?->diffForHumans(),
|
||||
'website_url' => $brand->website_url,
|
||||
@@ -2099,6 +2100,146 @@ class BrandController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect brand to CannaiQ API.
|
||||
*
|
||||
* Normalizes the brand name and stores as cannaiq_brand_key.
|
||||
*/
|
||||
public function cannaiqConnect(Request $request, Business $business, Brand $brand)
|
||||
{
|
||||
$this->authorize('update', [$brand, $business]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'brand_name' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
$brand->connectToCannaiq($validated['brand_name']);
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Brand connected to CannaiQ',
|
||||
'cannaiq_brand_key' => $brand->cannaiq_brand_key,
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.brands.dashboard', [$business->slug, $brand->hashid, 'tab' => 'settings'])
|
||||
->with('success', 'Brand connected to CannaiQ successfully!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect brand from CannaiQ API.
|
||||
*/
|
||||
public function cannaiqDisconnect(Request $request, Business $business, Brand $brand)
|
||||
{
|
||||
$this->authorize('update', [$brand, $business]);
|
||||
|
||||
$brand->disconnectFromCannaiq();
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Brand disconnected from CannaiQ',
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.brands.dashboard', [$business->slug, $brand->hashid, 'tab' => 'settings'])
|
||||
->with('success', 'Brand disconnected from CannaiQ.');
|
||||
}
|
||||
|
||||
/**
|
||||
* CannaiQ product mapping page.
|
||||
*
|
||||
* Shows Hub products for this brand and allows mapping to CannaiQ products.
|
||||
*/
|
||||
public function cannaiqMapping(Business $business, Brand $brand)
|
||||
{
|
||||
$this->authorize('view', [$brand, $business]);
|
||||
|
||||
if (! $brand->isCannaiqConnected()) {
|
||||
return redirect()
|
||||
->route('seller.business.brands.dashboard', [$business->slug, $brand->hashid, 'tab' => 'settings'])
|
||||
->with('error', 'Please connect this brand to CannaiQ first.');
|
||||
}
|
||||
|
||||
$products = $brand->products()
|
||||
->with('cannaiqMappings')
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('seller.brands.cannaiq-mapping', [
|
||||
'business' => $business,
|
||||
'brand' => $brand,
|
||||
'products' => $products,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a Hub product to a CannaiQ product.
|
||||
*/
|
||||
public function cannaiqMapProduct(Request $request, Business $business, Brand $brand)
|
||||
{
|
||||
$this->authorize('update', [$brand, $business]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'product_id' => 'required|exists:products,id',
|
||||
'cannaiq_product_id' => 'required|integer',
|
||||
'cannaiq_product_name' => 'required|string|max:255',
|
||||
'cannaiq_store_id' => 'nullable|string|max:255',
|
||||
'cannaiq_store_name' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
// Verify product belongs to this brand
|
||||
$product = $brand->products()->findOrFail($validated['product_id']);
|
||||
|
||||
// Create mapping (ignore if already exists)
|
||||
$mapping = $product->cannaiqMappings()->firstOrCreate(
|
||||
['cannaiq_product_id' => $validated['cannaiq_product_id']],
|
||||
[
|
||||
'cannaiq_product_name' => $validated['cannaiq_product_name'],
|
||||
'cannaiq_store_id' => $validated['cannaiq_store_id'] ?? null,
|
||||
'cannaiq_store_name' => $validated['cannaiq_store_name'] ?? null,
|
||||
]
|
||||
);
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'mapping' => $mapping,
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.brands.cannaiq.mapping', [$business->slug, $brand->hashid])
|
||||
->with('success', 'Product mapped successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a product mapping.
|
||||
*/
|
||||
public function cannaiqUnmapProduct(Request $request, Business $business, Brand $brand, \App\Models\ProductCannaiqMapping $mapping)
|
||||
{
|
||||
$this->authorize('update', [$brand, $business]);
|
||||
|
||||
// Verify mapping belongs to a product of this brand
|
||||
if ($mapping->product->brand_id !== $brand->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$mapping->delete();
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.brands.cannaiq.mapping', [$business->slug, $brand->hashid])
|
||||
->with('success', 'Mapping removed.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate store/distribution metrics for the brand.
|
||||
*
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Seller;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Brand;
|
||||
use App\Models\Business;
|
||||
use App\Models\Crm\CrmThread;
|
||||
use App\Models\Order;
|
||||
use App\Models\Product;
|
||||
use App\Models\Promotion;
|
||||
@@ -268,19 +269,26 @@ class BrandPortalController extends Controller
|
||||
/**
|
||||
* Inbox - Conversations (messaging).
|
||||
*
|
||||
* Shows conversations related to the business.
|
||||
* Uses existing messaging infrastructure but scoped to Brand Portal context.
|
||||
* Shows CRM threads related to the user's linked brands only.
|
||||
* Uses CrmThread scoped by brand_id for filtering.
|
||||
*/
|
||||
public function inbox(Request $request, Business $business)
|
||||
{
|
||||
$brandIds = $this->validateAccessAndGetBrandIds($business);
|
||||
$brands = Brand::whereIn('id', $brandIds)->get();
|
||||
|
||||
// For inbox, we show conversations but in a limited Brand Portal context
|
||||
// This integrates with existing messaging system
|
||||
// Get threads filtered to only those related to linked brands
|
||||
$threads = CrmThread::forBusiness($business->id)
|
||||
->forBrandPortal($brandIds)
|
||||
->with(['contact', 'assignee', 'brand'])
|
||||
->withCount('messages')
|
||||
->orderByDesc('last_message_at')
|
||||
->paginate(25);
|
||||
|
||||
return view('seller.brand-portal.inbox', compact(
|
||||
'business',
|
||||
'brands'
|
||||
'brands',
|
||||
'threads'
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -39,11 +39,16 @@ class AccountController extends Controller
|
||||
});
|
||||
}
|
||||
|
||||
// Status filter - default to approved, but allow viewing all
|
||||
if ($request->filled('status') && $request->status !== 'all') {
|
||||
$query->where('status', $request->status);
|
||||
} else {
|
||||
$query->where('status', 'approved');
|
||||
// Only show approved accounts (approved buyers)
|
||||
$query->where('status', 'approved');
|
||||
|
||||
// Active/Inactive filter
|
||||
if ($request->filled('status')) {
|
||||
if ($request->status === 'active') {
|
||||
$query->where('is_active', true);
|
||||
} elseif ($request->status === 'inactive') {
|
||||
$query->where('is_active', false);
|
||||
}
|
||||
}
|
||||
|
||||
$accounts = $query->orderBy('name')->paginate(25);
|
||||
|
||||
@@ -165,7 +165,7 @@ class InvoiceController extends Controller
|
||||
'title' => 'required|string|max:255',
|
||||
'contact_id' => 'required|exists:contacts,id',
|
||||
'account_id' => 'nullable|exists:businesses,id',
|
||||
'location_id' => 'nullable|exists:business_locations,id',
|
||||
'location_id' => 'nullable|exists:locations,id',
|
||||
'quote_id' => 'nullable|exists:crm_quotes,id',
|
||||
'deal_id' => 'nullable|exists:crm_deals,id',
|
||||
'due_date' => 'required|date|after_or_equal:today',
|
||||
@@ -309,7 +309,7 @@ class InvoiceController extends Controller
|
||||
'title' => 'required|string|max:255',
|
||||
'contact_id' => 'required|exists:contacts,id',
|
||||
'account_id' => 'nullable|exists:businesses,id',
|
||||
'location_id' => 'nullable|exists:business_locations,id',
|
||||
'location_id' => 'nullable|exists:locations,id',
|
||||
'deal_id' => 'nullable|exists:crm_deals,id',
|
||||
'due_date' => 'required|date',
|
||||
'tax_rate' => 'nullable|numeric|min:0|max:100',
|
||||
|
||||
@@ -246,7 +246,7 @@ class QuoteController extends Controller
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$quote->load(['contact', 'account', 'deal', 'creator', 'items.product', 'invoice', 'files']);
|
||||
$quote->load(['contact', 'account', 'deal', 'creator', 'items.product.brand', 'invoice', 'files']);
|
||||
|
||||
return view('seller.crm.quotes.show', compact('quote', 'business'));
|
||||
}
|
||||
@@ -581,7 +581,7 @@ class QuoteController extends Controller
|
||||
'sellerBusiness' => $business,
|
||||
]);
|
||||
|
||||
return $pdf->inline("{$quote->quote_number}.pdf");
|
||||
return $pdf->stream("{$quote->quote_number}.pdf");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -768,10 +768,19 @@ class ThreadController extends Controller
|
||||
|
||||
$threads = $query->get();
|
||||
|
||||
// Get team members
|
||||
// Get team members with their status
|
||||
$teamMemberStatuses = AgentStatus::where('business_id', $business->id)
|
||||
->where('last_seen_at', '>=', now()->subMinutes(5))
|
||||
->pluck('status', 'user_id');
|
||||
|
||||
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
|
||||
->select('id', 'name')
|
||||
->get();
|
||||
->select('id', 'first_name', 'last_name')
|
||||
->get()
|
||||
->map(fn ($member) => [
|
||||
'id' => $member->id,
|
||||
'name' => trim($member->first_name.' '.$member->last_name),
|
||||
'status' => $teamMemberStatuses[$member->id] ?? 'offline',
|
||||
]);
|
||||
|
||||
// Get agent status
|
||||
$agentStatus = AgentStatus::where('business_id', $business->id)
|
||||
|
||||
@@ -29,10 +29,10 @@ class ProductController extends Controller
|
||||
// Get brand IDs to filter by (respects brand context switcher)
|
||||
$brandIds = BrandSwitcherController::getFilteredBrandIds();
|
||||
|
||||
// Get all brands for the business for the filter dropdown
|
||||
// Get all brands for the business for the filter dropdown and new product button
|
||||
$brands = \App\Models\Brand::where('business_id', $business->id)
|
||||
->orderBy('name')
|
||||
->get(['id', 'name']);
|
||||
->get(['id', 'name', 'hashid', 'logo_path', 'slug', 'updated_at']);
|
||||
|
||||
// Calculate missing BOM count for health alert
|
||||
$missingBomCount = Product::whereIn('brand_id', $brandIds)
|
||||
@@ -881,9 +881,9 @@ class ProductController extends Controller
|
||||
'content' => [
|
||||
'description' => ['nullable', 'string', 'max:255'],
|
||||
'tagline' => ['nullable', 'string', 'max:100'],
|
||||
'long_description' => ['nullable', 'string', 'max:5000'],
|
||||
'consumer_long_description' => ['nullable', 'string', 'max:5000'],
|
||||
'buyer_long_description' => ['nullable', 'string', 'max:5000'],
|
||||
'long_description' => ['nullable', 'string'],
|
||||
'consumer_long_description' => ['nullable', 'string'],
|
||||
'buyer_long_description' => ['nullable', 'string'],
|
||||
'product_link' => 'nullable|url|max:255',
|
||||
'creatives_json' => 'nullable|json',
|
||||
'seo_title' => ['nullable', 'string', 'max:70'],
|
||||
|
||||
@@ -70,8 +70,8 @@ class ProductImageController extends Controller
|
||||
'id' => $image->id,
|
||||
'path' => $image->path,
|
||||
'is_primary' => $image->is_primary,
|
||||
'url' => route('image.product', ['product' => $product->hashid, 'width' => 400]),
|
||||
'thumb_url' => route('image.product', ['product' => $product->hashid, 'width' => 80]),
|
||||
'url' => route('image.product-image', ['productImage' => $image->id, 'width' => 400]),
|
||||
'thumb_url' => route('image.product-image', ['productImage' => $image->id, 'width' => 80]),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
263
app/Http/Controllers/Seller/Settings/DbaController.php
Normal file
263
app/Http/Controllers/Seller/Settings/DbaController.php
Normal file
@@ -0,0 +1,263 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\BusinessDba;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class DbaController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of all DBAs for the business.
|
||||
*/
|
||||
public function index(Business $business): View
|
||||
{
|
||||
$dbas = $business->dbas()
|
||||
->orderByDesc('is_default')
|
||||
->orderBy('trade_name')
|
||||
->get();
|
||||
|
||||
return view('seller.settings.dbas.index', compact('business', 'dbas'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for creating a new DBA.
|
||||
*/
|
||||
public function create(Business $business): View
|
||||
{
|
||||
return view('seller.settings.dbas.create', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created DBA in storage.
|
||||
*/
|
||||
public function store(Request $request, Business $business): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
// Identity
|
||||
'trade_name' => 'required|string|max:255',
|
||||
|
||||
// Address
|
||||
'address' => 'nullable|string|max:255',
|
||||
'address_line_2' => 'nullable|string|max:255',
|
||||
'city' => 'nullable|string|max:255',
|
||||
'state' => 'nullable|string|max:2',
|
||||
'zip' => 'nullable|string|max:10',
|
||||
|
||||
// License
|
||||
'license_number' => 'nullable|string|max:255',
|
||||
'license_type' => 'nullable|string|max:255',
|
||||
'license_expiration' => 'nullable|date',
|
||||
|
||||
// Bank Info
|
||||
'bank_name' => 'nullable|string|max:255',
|
||||
'bank_account_name' => 'nullable|string|max:255',
|
||||
'bank_routing_number' => 'nullable|string|max:50',
|
||||
'bank_account_number' => 'nullable|string|max:50',
|
||||
'bank_account_type' => 'nullable|string|in:checking,savings',
|
||||
|
||||
// Tax
|
||||
'tax_id' => 'nullable|string|max:50',
|
||||
'tax_id_type' => 'nullable|string|in:ein,ssn',
|
||||
|
||||
// Contacts
|
||||
'primary_contact_name' => 'nullable|string|max:255',
|
||||
'primary_contact_email' => 'nullable|email|max:255',
|
||||
'primary_contact_phone' => 'nullable|string|max:50',
|
||||
'ap_contact_name' => 'nullable|string|max:255',
|
||||
'ap_contact_email' => 'nullable|email|max:255',
|
||||
'ap_contact_phone' => 'nullable|string|max:50',
|
||||
|
||||
// Invoice Settings
|
||||
'payment_terms' => 'nullable|string|max:50',
|
||||
'payment_instructions' => 'nullable|string|max:2000',
|
||||
'invoice_footer' => 'nullable|string|max:2000',
|
||||
'invoice_prefix' => 'nullable|string|max:10',
|
||||
|
||||
// Branding
|
||||
'logo_path' => 'nullable|string|max:255',
|
||||
'brand_colors' => 'nullable|array',
|
||||
|
||||
// Status
|
||||
'is_default' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
$validated['business_id'] = $business->id;
|
||||
$validated['is_default'] = $request->boolean('is_default');
|
||||
$validated['is_active'] = $request->boolean('is_active', true);
|
||||
|
||||
$dba = BusinessDba::create($validated);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.settings.dbas.index', $business)
|
||||
->with('success', "DBA \"{$dba->trade_name}\" created successfully.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for editing the specified DBA.
|
||||
*/
|
||||
public function edit(Business $business, BusinessDba $dba): View
|
||||
{
|
||||
// Verify DBA belongs to this business
|
||||
if ($dba->business_id !== $business->id) {
|
||||
abort(403, 'This DBA does not belong to your business.');
|
||||
}
|
||||
|
||||
return view('seller.settings.dbas.edit', compact('business', 'dba'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified DBA in storage.
|
||||
*/
|
||||
public function update(Request $request, Business $business, BusinessDba $dba): RedirectResponse
|
||||
{
|
||||
// Verify DBA belongs to this business
|
||||
if ($dba->business_id !== $business->id) {
|
||||
abort(403, 'This DBA does not belong to your business.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
// Identity
|
||||
'trade_name' => 'required|string|max:255',
|
||||
|
||||
// Address
|
||||
'address' => 'nullable|string|max:255',
|
||||
'address_line_2' => 'nullable|string|max:255',
|
||||
'city' => 'nullable|string|max:255',
|
||||
'state' => 'nullable|string|max:2',
|
||||
'zip' => 'nullable|string|max:10',
|
||||
|
||||
// License
|
||||
'license_number' => 'nullable|string|max:255',
|
||||
'license_type' => 'nullable|string|max:255',
|
||||
'license_expiration' => 'nullable|date',
|
||||
|
||||
// Bank Info
|
||||
'bank_name' => 'nullable|string|max:255',
|
||||
'bank_account_name' => 'nullable|string|max:255',
|
||||
'bank_routing_number' => 'nullable|string|max:50',
|
||||
'bank_account_number' => 'nullable|string|max:50',
|
||||
'bank_account_type' => 'nullable|string|in:checking,savings',
|
||||
|
||||
// Tax
|
||||
'tax_id' => 'nullable|string|max:50',
|
||||
'tax_id_type' => 'nullable|string|in:ein,ssn',
|
||||
|
||||
// Contacts
|
||||
'primary_contact_name' => 'nullable|string|max:255',
|
||||
'primary_contact_email' => 'nullable|email|max:255',
|
||||
'primary_contact_phone' => 'nullable|string|max:50',
|
||||
'ap_contact_name' => 'nullable|string|max:255',
|
||||
'ap_contact_email' => 'nullable|email|max:255',
|
||||
'ap_contact_phone' => 'nullable|string|max:50',
|
||||
|
||||
// Invoice Settings
|
||||
'payment_terms' => 'nullable|string|max:50',
|
||||
'payment_instructions' => 'nullable|string|max:2000',
|
||||
'invoice_footer' => 'nullable|string|max:2000',
|
||||
'invoice_prefix' => 'nullable|string|max:10',
|
||||
|
||||
// Branding
|
||||
'logo_path' => 'nullable|string|max:255',
|
||||
'brand_colors' => 'nullable|array',
|
||||
|
||||
// Status
|
||||
'is_default' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
$validated['is_default'] = $request->boolean('is_default');
|
||||
$validated['is_active'] = $request->boolean('is_active', true);
|
||||
|
||||
// Don't overwrite encrypted fields if left blank (preserve existing values)
|
||||
$encryptedFields = ['bank_routing_number', 'bank_account_number', 'tax_id'];
|
||||
foreach ($encryptedFields as $field) {
|
||||
if (empty($validated[$field])) {
|
||||
unset($validated[$field]);
|
||||
}
|
||||
}
|
||||
|
||||
$dba->update($validated);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.settings.dbas.index', $business)
|
||||
->with('success', "DBA \"{$dba->trade_name}\" updated successfully.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified DBA from storage.
|
||||
*/
|
||||
public function destroy(Business $business, BusinessDba $dba): RedirectResponse
|
||||
{
|
||||
// Verify DBA belongs to this business
|
||||
if ($dba->business_id !== $business->id) {
|
||||
abort(403, 'This DBA does not belong to your business.');
|
||||
}
|
||||
|
||||
// Check if this is the only active DBA
|
||||
$activeCount = $business->dbas()->where('is_active', true)->count();
|
||||
if ($activeCount <= 1 && $dba->is_active) {
|
||||
return redirect()
|
||||
->route('seller.business.settings.dbas.index', $business)
|
||||
->with('error', 'You cannot delete the only active DBA. Create another DBA first or deactivate this one.');
|
||||
}
|
||||
|
||||
$tradeName = $dba->trade_name;
|
||||
$dba->delete();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.settings.dbas.index', $business)
|
||||
->with('success', "DBA \"{$tradeName}\" deleted successfully.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the specified DBA as the default for the business.
|
||||
*/
|
||||
public function setDefault(Business $business, BusinessDba $dba): RedirectResponse
|
||||
{
|
||||
// Verify DBA belongs to this business
|
||||
if ($dba->business_id !== $business->id) {
|
||||
abort(403, 'This DBA does not belong to your business.');
|
||||
}
|
||||
|
||||
$dba->markAsDefault();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.settings.dbas.index', $business)
|
||||
->with('success', "\"{$dba->trade_name}\" is now your default DBA for invoices.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the active status of a DBA.
|
||||
*/
|
||||
public function toggleActive(Business $business, BusinessDba $dba): RedirectResponse
|
||||
{
|
||||
// Verify DBA belongs to this business
|
||||
if ($dba->business_id !== $business->id) {
|
||||
abort(403, 'This DBA does not belong to your business.');
|
||||
}
|
||||
|
||||
// Prevent deactivating if it's the only active DBA
|
||||
if ($dba->is_active) {
|
||||
$activeCount = $business->dbas()->where('is_active', true)->count();
|
||||
if ($activeCount <= 1) {
|
||||
return redirect()
|
||||
->route('seller.business.settings.dbas.index', $business)
|
||||
->with('error', 'You cannot deactivate the only active DBA.');
|
||||
}
|
||||
}
|
||||
|
||||
$dba->update(['is_active' => ! $dba->is_active]);
|
||||
|
||||
$status = $dba->is_active ? 'activated' : 'deactivated';
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.settings.dbas.index', $business)
|
||||
->with('success', "DBA \"{$dba->trade_name}\" has been {$status}.");
|
||||
}
|
||||
}
|
||||
66
app/Jobs/RollupBannerAdStats.php
Normal file
66
app/Jobs/RollupBannerAdStats.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\BannerAdDailyStat;
|
||||
use App\Models\BannerAdEvent;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class RollupBannerAdStats implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
protected ?string $date = null
|
||||
) {
|
||||
$this->date = $date ?? now()->subDay()->toDateString();
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$stats = BannerAdEvent::query()
|
||||
->whereDate('created_at', $this->date)
|
||||
->select([
|
||||
'banner_ad_id',
|
||||
DB::raw("SUM(CASE WHEN event_type = 'impression' THEN 1 ELSE 0 END) as impressions"),
|
||||
DB::raw("SUM(CASE WHEN event_type = 'click' THEN 1 ELSE 0 END) as clicks"),
|
||||
DB::raw("COUNT(DISTINCT CASE WHEN event_type = 'impression' THEN session_id END) as unique_impressions"),
|
||||
DB::raw("COUNT(DISTINCT CASE WHEN event_type = 'click' THEN session_id END) as unique_clicks"),
|
||||
])
|
||||
->groupBy('banner_ad_id')
|
||||
->get();
|
||||
|
||||
$created = 0;
|
||||
foreach ($stats as $stat) {
|
||||
BannerAdDailyStat::updateOrCreate(
|
||||
[
|
||||
'banner_ad_id' => $stat->banner_ad_id,
|
||||
'date' => $this->date,
|
||||
],
|
||||
[
|
||||
'impressions' => $stat->impressions,
|
||||
'clicks' => $stat->clicks,
|
||||
'unique_impressions' => $stat->unique_impressions,
|
||||
'unique_clicks' => $stat->unique_clicks,
|
||||
]
|
||||
);
|
||||
$created++;
|
||||
}
|
||||
|
||||
if ($created > 0) {
|
||||
Log::info("Banner ad daily stats rolled up: {$created} records for {$this->date}");
|
||||
}
|
||||
|
||||
// Optionally clean up old events (older than 30 days)
|
||||
$deleted = BannerAdEvent::where('created_at', '<', now()->subDays(30))->delete();
|
||||
if ($deleted > 0) {
|
||||
Log::info("Cleaned up {$deleted} old banner ad events");
|
||||
}
|
||||
}
|
||||
}
|
||||
25
app/Jobs/UpdateBannerAdStatuses.php
Normal file
25
app/Jobs/UpdateBannerAdStatuses.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Services\BannerAdService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class UpdateBannerAdStatuses implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function handle(BannerAdService $service): void
|
||||
{
|
||||
$updated = $service->updateScheduledStatuses();
|
||||
|
||||
if ($updated > 0) {
|
||||
Log::info("Banner ad statuses updated: {$updated} ads changed");
|
||||
}
|
||||
}
|
||||
}
|
||||
56
app/Mail/Concerns/HasBusinessReplyTo.php
Normal file
56
app/Mail/Concerns/HasBusinessReplyTo.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail\Concerns;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Models\User;
|
||||
use Illuminate\Mail\Mailables\Address;
|
||||
|
||||
/**
|
||||
* Trait for adding Reply-To header using business's primary email identity.
|
||||
*
|
||||
* This ensures replies to transactional emails (quotes, invoices, orders)
|
||||
* are routed back to the CRM inbox.
|
||||
*
|
||||
* Supports plus addressing: inbox+user123@domain.com routes replies
|
||||
* to the specific user who sent the original message.
|
||||
*/
|
||||
trait HasBusinessReplyTo
|
||||
{
|
||||
/**
|
||||
* Get the Reply-To addresses for the business.
|
||||
*
|
||||
* @param User|int|null $user Optional user for plus addressing
|
||||
* @return array<Address>
|
||||
*/
|
||||
protected function getBusinessReplyTo(Business $business, User|int|null $user = null): array
|
||||
{
|
||||
$inboundEmail = $business->primaryEmailIdentity?->email;
|
||||
|
||||
if (! $inboundEmail) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Add plus addressing for user routing
|
||||
if ($user) {
|
||||
$userId = $user instanceof User ? $user->id : $user;
|
||||
$inboundEmail = $this->addPlusAddress($inboundEmail, "u{$userId}");
|
||||
}
|
||||
|
||||
return [new Address($inboundEmail, $business->name)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add plus addressing to an email.
|
||||
* inbox@domain.com + "u123" => inbox+u123@domain.com
|
||||
*/
|
||||
protected function addPlusAddress(string $email, string $tag): string
|
||||
{
|
||||
$parts = explode('@', $email);
|
||||
if (count($parts) !== 2) {
|
||||
return $email;
|
||||
}
|
||||
|
||||
return $parts[0].'+'.$tag.'@'.$parts[1];
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Mail\Invoices;
|
||||
|
||||
use App\Mail\Concerns\HasBusinessReplyTo;
|
||||
use App\Models\Invoice;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Attachment;
|
||||
@@ -12,7 +13,7 @@ use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class InvoiceSentMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
use HasBusinessReplyTo, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public Invoice $invoice,
|
||||
@@ -22,9 +23,19 @@ class InvoiceSentMail extends Mailable
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: "Invoice {$this->invoice->invoice_number} from ".config('app.name'),
|
||||
$business = $this->invoice->sellerBusiness;
|
||||
$envelope = new Envelope(
|
||||
subject: "Invoice {$this->invoice->invoice_number} from ".($business?->name ?? config('app.name')),
|
||||
);
|
||||
|
||||
if ($business) {
|
||||
$replyTo = $this->getBusinessReplyTo($business);
|
||||
if ($replyTo) {
|
||||
$envelope->replyTo($replyTo);
|
||||
}
|
||||
}
|
||||
|
||||
return $envelope;
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Mail\Orders;
|
||||
|
||||
use App\Mail\Concerns\HasBusinessReplyTo;
|
||||
use App\Models\Order;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
@@ -11,7 +12,7 @@ use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class OrderAcceptedMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
use HasBusinessReplyTo, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new message instance.
|
||||
@@ -25,9 +26,19 @@ class OrderAcceptedMail extends Mailable
|
||||
*/
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
$business = $this->order->sellerBusiness;
|
||||
$envelope = new Envelope(
|
||||
subject: "Order {$this->order->order_number} Accepted",
|
||||
);
|
||||
|
||||
if ($business) {
|
||||
$replyTo = $this->getBusinessReplyTo($business);
|
||||
if ($replyTo) {
|
||||
$envelope->replyTo($replyTo);
|
||||
}
|
||||
}
|
||||
|
||||
return $envelope;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Mail\Orders;
|
||||
|
||||
use App\Mail\Concerns\HasBusinessReplyTo;
|
||||
use App\Models\Order;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
@@ -11,7 +12,7 @@ use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class OrderDeliveredMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
use HasBusinessReplyTo, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public Order $order
|
||||
@@ -19,9 +20,19 @@ class OrderDeliveredMail extends Mailable
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
$business = $this->order->sellerBusiness;
|
||||
$envelope = new Envelope(
|
||||
subject: "Order {$this->order->order_number} Delivered",
|
||||
);
|
||||
|
||||
if ($business) {
|
||||
$replyTo = $this->getBusinessReplyTo($business);
|
||||
if ($replyTo) {
|
||||
$envelope->replyTo($replyTo);
|
||||
}
|
||||
}
|
||||
|
||||
return $envelope;
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Mail\Orders;
|
||||
|
||||
use App\Mail\Concerns\HasBusinessReplyTo;
|
||||
use App\Models\Order;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
@@ -11,7 +12,7 @@ use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class OrderReadyForDeliveryMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
use HasBusinessReplyTo, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public Order $order
|
||||
@@ -19,9 +20,19 @@ class OrderReadyForDeliveryMail extends Mailable
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
$business = $this->order->sellerBusiness;
|
||||
$envelope = new Envelope(
|
||||
subject: "Order {$this->order->order_number} Ready for Delivery",
|
||||
);
|
||||
|
||||
if ($business) {
|
||||
$replyTo = $this->getBusinessReplyTo($business);
|
||||
if ($replyTo) {
|
||||
$envelope->replyTo($replyTo);
|
||||
}
|
||||
}
|
||||
|
||||
return $envelope;
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Mail\Concerns\HasBusinessReplyTo;
|
||||
use App\Models\Business;
|
||||
use App\Models\Crm\CrmQuote;
|
||||
use Illuminate\Bus\Queueable;
|
||||
@@ -14,7 +15,7 @@ use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class QuoteMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
use HasBusinessReplyTo, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public CrmQuote $quote,
|
||||
@@ -25,9 +26,16 @@ class QuoteMail extends Mailable
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
$envelope = new Envelope(
|
||||
subject: "Quote {$this->quote->quote_number} from {$this->business->name}",
|
||||
);
|
||||
|
||||
$replyTo = $this->getBusinessReplyTo($this->business);
|
||||
if ($replyTo) {
|
||||
$envelope->replyTo($replyTo);
|
||||
}
|
||||
|
||||
return $envelope;
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
|
||||
@@ -57,6 +57,11 @@ class Activity extends Model
|
||||
'task.reminder_sent' => ['icon' => 'heroicons--bell', 'color' => 'text-warning', 'label' => 'Reminder Sent'],
|
||||
'event.reminder_sent' => ['icon' => 'heroicons--bell', 'color' => 'text-warning', 'label' => 'Reminder Sent'],
|
||||
|
||||
// Email engagement activities
|
||||
'email.opened' => ['icon' => 'heroicons--envelope-open', 'color' => 'text-success', 'label' => 'Email Opened'],
|
||||
'email.clicked' => ['icon' => 'heroicons--cursor-arrow-rays', 'color' => 'text-info', 'label' => 'Email Link Clicked'],
|
||||
'email.bounced' => ['icon' => 'heroicons--exclamation-circle', 'color' => 'text-error', 'label' => 'Email Bounced'],
|
||||
|
||||
// Generic
|
||||
'note.added' => ['icon' => 'heroicons--document-text', 'color' => 'text-base-content', 'label' => 'Note Added'],
|
||||
];
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
namespace App\Models\Analytics;
|
||||
|
||||
use App\Models\Activity;
|
||||
use App\Models\Business;
|
||||
use App\Models\Contact;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -118,6 +120,9 @@ class EmailInteraction extends Model
|
||||
// Update campaign stats
|
||||
if ($isFirstOpen && $this->campaign) {
|
||||
$this->campaign->increment('opened_count');
|
||||
|
||||
// Log activity for first open only
|
||||
$this->logActivity('email.opened', "Opened '{$this->campaign->subject}'");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +152,10 @@ class EmailInteraction extends Model
|
||||
// Update campaign stats
|
||||
if ($isFirstClick && $this->campaign) {
|
||||
$this->campaign->increment('clicked_count');
|
||||
|
||||
// Log activity for first click only
|
||||
$subject = $this->campaign->subject;
|
||||
$this->logActivity('email.clicked', "Clicked link in '{$subject}'", ['url' => $url]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,4 +188,32 @@ class EmailInteraction extends Model
|
||||
|
||||
$this->update(['engagement_score' => min(100, $score)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log activity for email engagement.
|
||||
* Finds contact by email and logs activity to their timeline.
|
||||
*/
|
||||
protected function logActivity(string $type, string $description, ?array $meta = null): void
|
||||
{
|
||||
// Find contact by email in the sender's business
|
||||
$contact = Contact::where('email', $this->recipient_email)->first();
|
||||
|
||||
if (! $contact || ! $this->business_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
Activity::create([
|
||||
'seller_business_id' => $this->business_id,
|
||||
'business_id' => $contact->business_id,
|
||||
'contact_id' => $contact->id,
|
||||
'subject_type' => self::class,
|
||||
'subject_id' => $this->id,
|
||||
'type' => $type,
|
||||
'description' => $description,
|
||||
'meta' => array_merge($meta ?? [], [
|
||||
'campaign_id' => $this->email_campaign_id,
|
||||
'interaction_id' => $this->id,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
201
app/Models/BannerAd.php
Normal file
201
app/Models/BannerAd.php
Normal file
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\BannerAdStatus;
|
||||
use App\Enums\BannerAdZone;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class BannerAd extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'brand_id',
|
||||
'created_by_user_id',
|
||||
'name',
|
||||
'headline',
|
||||
'description',
|
||||
'cta_text',
|
||||
'cta_url',
|
||||
'image_path',
|
||||
'image_alt',
|
||||
'zone',
|
||||
'starts_at',
|
||||
'ends_at',
|
||||
'target_business_types',
|
||||
'is_platform_wide',
|
||||
'status',
|
||||
'priority',
|
||||
'weight',
|
||||
'impressions',
|
||||
'clicks',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'starts_at' => 'datetime',
|
||||
'ends_at' => 'datetime',
|
||||
'target_business_types' => 'array',
|
||||
'is_platform_wide' => 'boolean',
|
||||
'status' => BannerAdStatus::class,
|
||||
'zone' => BannerAdZone::class,
|
||||
'impressions' => 'integer',
|
||||
'clicks' => 'integer',
|
||||
'priority' => 'integer',
|
||||
'weight' => 'integer',
|
||||
];
|
||||
|
||||
// Relationships
|
||||
|
||||
public function brand(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Brand::class);
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by_user_id');
|
||||
}
|
||||
|
||||
public function events(): HasMany
|
||||
{
|
||||
return $this->hasMany(BannerAdEvent::class);
|
||||
}
|
||||
|
||||
public function dailyStats(): HasMany
|
||||
{
|
||||
return $this->hasMany(BannerAdDailyStat::class);
|
||||
}
|
||||
|
||||
// Scopes
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('status', BannerAdStatus::ACTIVE)
|
||||
->where(function ($q) {
|
||||
$q->whereNull('starts_at')->orWhere('starts_at', '<=', now());
|
||||
})
|
||||
->where(function ($q) {
|
||||
$q->whereNull('ends_at')->orWhere('ends_at', '>=', now());
|
||||
});
|
||||
}
|
||||
|
||||
public function scopeForZone($query, BannerAdZone|string $zone)
|
||||
{
|
||||
$zoneValue = $zone instanceof BannerAdZone ? $zone->value : $zone;
|
||||
|
||||
return $query->where('zone', $zoneValue);
|
||||
}
|
||||
|
||||
public function scopePlatformWide($query)
|
||||
{
|
||||
return $query->where('is_platform_wide', true);
|
||||
}
|
||||
|
||||
public function scopeForBusinessType($query, ?string $businessType)
|
||||
{
|
||||
if (! $businessType) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return $query->where(function ($q) use ($businessType) {
|
||||
$q->whereNull('target_business_types')
|
||||
->orWhereJsonContains('target_business_types', $businessType);
|
||||
});
|
||||
}
|
||||
|
||||
public function scopeScheduled($query)
|
||||
{
|
||||
return $query->where('status', BannerAdStatus::SCHEDULED)
|
||||
->whereNotNull('starts_at')
|
||||
->where('starts_at', '>', now());
|
||||
}
|
||||
|
||||
public function scopeExpired($query)
|
||||
{
|
||||
return $query->where('status', BannerAdStatus::EXPIRED);
|
||||
}
|
||||
|
||||
// Accessors
|
||||
|
||||
public function getImageUrlAttribute(): ?string
|
||||
{
|
||||
if (! $this->image_path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Storage::url($this->image_path);
|
||||
}
|
||||
|
||||
public function getClickThroughRateAttribute(): float
|
||||
{
|
||||
if ($this->impressions === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return round(($this->clicks / $this->impressions) * 100, 2);
|
||||
}
|
||||
|
||||
public function getIsCurrentlyActiveAttribute(): bool
|
||||
{
|
||||
if ($this->status !== BannerAdStatus::ACTIVE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$now = now();
|
||||
|
||||
if ($this->starts_at && $this->starts_at > $now) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->ends_at && $this->ends_at < $now) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getDimensionsAttribute(): array
|
||||
{
|
||||
return $this->zone?->dimensions() ?? ['width' => 728, 'height' => 90, 'display' => '728x90'];
|
||||
}
|
||||
|
||||
// Methods
|
||||
|
||||
public function incrementImpressions(): void
|
||||
{
|
||||
$this->increment('impressions');
|
||||
}
|
||||
|
||||
public function incrementClicks(): void
|
||||
{
|
||||
$this->increment('clicks');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image URL for serving via controller
|
||||
*/
|
||||
public function getImageUrl(?int $width = null): ?string
|
||||
{
|
||||
if (! $this->image_path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return route('image.banner-ad', [
|
||||
'bannerAd' => $this->id,
|
||||
'width' => $width,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get click tracking URL
|
||||
*/
|
||||
public function getClickUrl(): string
|
||||
{
|
||||
return route('banner-ad.click', $this->id);
|
||||
}
|
||||
}
|
||||
65
app/Models/BannerAdDailyStat.php
Normal file
65
app/Models/BannerAdDailyStat.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class BannerAdDailyStat extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'banner_ad_id',
|
||||
'date',
|
||||
'impressions',
|
||||
'clicks',
|
||||
'unique_impressions',
|
||||
'unique_clicks',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'date' => 'date',
|
||||
'impressions' => 'integer',
|
||||
'clicks' => 'integer',
|
||||
'unique_impressions' => 'integer',
|
||||
'unique_clicks' => 'integer',
|
||||
];
|
||||
|
||||
// Relationships
|
||||
|
||||
public function bannerAd(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(BannerAd::class);
|
||||
}
|
||||
|
||||
// Accessors
|
||||
|
||||
public function getClickThroughRateAttribute(): float
|
||||
{
|
||||
if ($this->impressions === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return round(($this->clicks / $this->impressions) * 100, 2);
|
||||
}
|
||||
|
||||
public function getUniqueClickThroughRateAttribute(): float
|
||||
{
|
||||
if ($this->unique_impressions === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return round(($this->unique_clicks / $this->unique_impressions) * 100, 2);
|
||||
}
|
||||
|
||||
// Scopes
|
||||
|
||||
public function scopeForDateRange($query, $startDate, $endDate)
|
||||
{
|
||||
return $query->whereBetween('date', [$startDate, $endDate]);
|
||||
}
|
||||
|
||||
public function scopeForAd($query, $bannerAdId)
|
||||
{
|
||||
return $query->where('banner_ad_id', $bannerAdId);
|
||||
}
|
||||
}
|
||||
74
app/Models/BannerAdEvent.php
Normal file
74
app/Models/BannerAdEvent.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class BannerAdEvent extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'banner_ad_id',
|
||||
'business_id',
|
||||
'user_id',
|
||||
'event_type',
|
||||
'session_id',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
'page_url',
|
||||
'referer',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (self $event) {
|
||||
$event->created_at = $event->created_at ?? now();
|
||||
});
|
||||
}
|
||||
|
||||
// Relationships
|
||||
|
||||
public function bannerAd(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(BannerAd::class);
|
||||
}
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
// Scopes
|
||||
|
||||
public function scopeImpressions($query)
|
||||
{
|
||||
return $query->where('event_type', 'impression');
|
||||
}
|
||||
|
||||
public function scopeClicks($query)
|
||||
{
|
||||
return $query->where('event_type', 'click');
|
||||
}
|
||||
|
||||
public function scopeForDate($query, $date)
|
||||
{
|
||||
return $query->whereDate('created_at', $date);
|
||||
}
|
||||
|
||||
public function scopeForDateRange($query, $startDate, $endDate)
|
||||
{
|
||||
return $query->whereBetween('created_at', [$startDate, $endDate]);
|
||||
}
|
||||
}
|
||||
@@ -129,6 +129,9 @@ class Brand extends Model implements Auditable
|
||||
|
||||
// CRM Channel for inbound emails
|
||||
'inbound_email_channel_id',
|
||||
|
||||
// CannaIQ Integration
|
||||
'cannaiq_brand_key', // Normalized brand name for CannaIQ API (e.g., "alohatymemachine")
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@@ -161,6 +164,14 @@ class Brand extends Model implements Auditable
|
||||
return $this->hasMany(Product::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Promotions for this brand
|
||||
*/
|
||||
public function promotions(): HasMany
|
||||
{
|
||||
return $this->hasMany(Promotion::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Menus for this brand (both system and user-created)
|
||||
*/
|
||||
@@ -325,6 +336,47 @@ class Brand extends Model implements Auditable
|
||||
->get();
|
||||
}
|
||||
|
||||
// CannaIQ Integration
|
||||
|
||||
/**
|
||||
* Check if brand is connected to CannaIQ
|
||||
*/
|
||||
public function isCannaiqConnected(): bool
|
||||
{
|
||||
return ! empty($this->cannaiq_brand_key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a brand name for CannaIQ API key
|
||||
* Removes spaces, special chars, converts to lowercase
|
||||
*
|
||||
* Example: "Aloha TymeMachine" → "alohatymemachine"
|
||||
*/
|
||||
public static function normalizeCannaiqKey(string $brandName): string
|
||||
{
|
||||
// Remove all non-alphanumeric characters and convert to lowercase
|
||||
return strtolower(preg_replace('/[^a-zA-Z0-9]/', '', $brandName));
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect brand to CannaIQ using brand name
|
||||
* Normalizes the name and stores as cannaiq_brand_key
|
||||
*/
|
||||
public function connectToCannaiq(string $brandName): void
|
||||
{
|
||||
$this->cannaiq_brand_key = self::normalizeCannaiqKey($brandName);
|
||||
$this->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect brand from CannaIQ
|
||||
*/
|
||||
public function disconnectFromCannaiq(): void
|
||||
{
|
||||
$this->cannaiq_brand_key = null;
|
||||
$this->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate slug from name
|
||||
*/
|
||||
|
||||
@@ -293,6 +293,7 @@ class Business extends Model implements AuditableContract
|
||||
'has_enterprise_suite',
|
||||
'use_suite_navigation',
|
||||
'cannaiq_enabled',
|
||||
'ping_pong_enabled',
|
||||
|
||||
// Sales Suite Usage Limits
|
||||
'sales_suite_brand_limit',
|
||||
@@ -368,6 +369,7 @@ class Business extends Model implements AuditableContract
|
||||
'is_enterprise_plan' => 'boolean', // Plan limit override - when true, usage limits are not enforced
|
||||
'use_suite_navigation' => 'boolean',
|
||||
'cannaiq_enabled' => 'boolean',
|
||||
'ping_pong_enabled' => 'boolean',
|
||||
// Sales Suite Usage Limits
|
||||
'sales_suite_brand_limit' => 'integer',
|
||||
'sales_suite_sku_limit_per_brand' => 'integer',
|
||||
@@ -531,6 +533,47 @@ class Business extends Model implements AuditableContract
|
||||
return $this->hasMany(Brand::class);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DBA (Doing Business As) Relationships
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Get all DBAs for this business.
|
||||
*/
|
||||
public function dbas(): HasMany
|
||||
{
|
||||
return $this->hasMany(BusinessDba::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active DBAs for this business.
|
||||
*/
|
||||
public function activeDbas(): HasMany
|
||||
{
|
||||
return $this->hasMany(BusinessDba::class)->where('is_active', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default DBA for this business.
|
||||
*/
|
||||
public function defaultDba(): HasOne
|
||||
{
|
||||
return $this->hasOne(BusinessDba::class)->where('is_default', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get DBA for invoice generation.
|
||||
* Priority: explicit dba_id > default DBA > first active DBA > null
|
||||
*/
|
||||
public function getDbaForInvoice(?int $dbaId = null): ?BusinessDba
|
||||
{
|
||||
if ($dbaId) {
|
||||
return $this->dbas()->find($dbaId);
|
||||
}
|
||||
|
||||
return $this->defaultDba ?? $this->activeDbas()->first();
|
||||
}
|
||||
|
||||
public function brandAiProfiles(): HasMany
|
||||
{
|
||||
return $this->hasMany(BrandAiProfile::class);
|
||||
@@ -612,6 +655,24 @@ class Business extends Model implements AuditableContract
|
||||
return $this->hasOne(BusinessSettings::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get email identities for inbound email routing.
|
||||
*/
|
||||
public function emailIdentities(): HasMany
|
||||
{
|
||||
return $this->hasMany(BusinessEmailIdentity::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the primary email identity (first active one).
|
||||
*/
|
||||
public function primaryEmailIdentity(): HasOne
|
||||
{
|
||||
return $this->hasOne(BusinessEmailIdentity::class)
|
||||
->where('is_active', true)
|
||||
->orderBy('id');
|
||||
}
|
||||
|
||||
public function approver()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'approved_by');
|
||||
|
||||
250
app/Models/BusinessDba.php
Normal file
250
app/Models/BusinessDba.php
Normal file
@@ -0,0 +1,250 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\BelongsToBusinessDirectly;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Str;
|
||||
use OwenIt\Auditing\Contracts\Auditable;
|
||||
|
||||
class BusinessDba extends Model implements Auditable
|
||||
{
|
||||
use BelongsToBusinessDirectly;
|
||||
use HasFactory;
|
||||
use \OwenIt\Auditing\Auditable;
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'business_dbas';
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'trade_name',
|
||||
'slug',
|
||||
// Address
|
||||
'address',
|
||||
'address_line_2',
|
||||
'city',
|
||||
'state',
|
||||
'zip',
|
||||
// License
|
||||
'license_number',
|
||||
'license_type',
|
||||
'license_expiration',
|
||||
// Bank Info
|
||||
'bank_name',
|
||||
'bank_account_name',
|
||||
'bank_routing_number',
|
||||
'bank_account_number',
|
||||
'bank_account_type',
|
||||
// Tax
|
||||
'tax_id',
|
||||
'tax_id_type',
|
||||
// Contacts
|
||||
'primary_contact_name',
|
||||
'primary_contact_email',
|
||||
'primary_contact_phone',
|
||||
'ap_contact_name',
|
||||
'ap_contact_email',
|
||||
'ap_contact_phone',
|
||||
// Invoice Settings
|
||||
'payment_terms',
|
||||
'payment_instructions',
|
||||
'invoice_footer',
|
||||
'invoice_prefix',
|
||||
// Branding
|
||||
'logo_path',
|
||||
'brand_colors',
|
||||
// Status
|
||||
'is_default',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'brand_colors' => 'array',
|
||||
'is_default' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
'license_expiration' => 'date',
|
||||
// Encrypted fields
|
||||
'bank_routing_number' => 'encrypted',
|
||||
'bank_account_number' => 'encrypted',
|
||||
'tax_id' => 'encrypted',
|
||||
];
|
||||
|
||||
/**
|
||||
* Fields to exclude from audit logging (sensitive data)
|
||||
*/
|
||||
protected array $auditExclude = [
|
||||
'bank_routing_number',
|
||||
'bank_account_number',
|
||||
'tax_id',
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// Relationships
|
||||
// =========================================================================
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function invoices(): HasMany
|
||||
{
|
||||
return $this->hasMany(\App\Models\Crm\CrmInvoice::class, 'dba_id');
|
||||
}
|
||||
|
||||
public function orders(): HasMany
|
||||
{
|
||||
return $this->hasMany(Order::class, 'seller_dba_id');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Scopes
|
||||
// =========================================================================
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public function scopeDefault($query)
|
||||
{
|
||||
return $query->where('is_default', true);
|
||||
}
|
||||
|
||||
public function scopeForBusiness($query, int $businessId)
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Accessors
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Get the full formatted address.
|
||||
*/
|
||||
public function getFullAddressAttribute(): string
|
||||
{
|
||||
$parts = array_filter([
|
||||
$this->address,
|
||||
$this->address_line_2,
|
||||
]);
|
||||
|
||||
$cityStateZip = trim(
|
||||
($this->city ?? '').
|
||||
($this->city && $this->state ? ', ' : '').
|
||||
($this->state ?? '').' '.
|
||||
($this->zip ?? '')
|
||||
);
|
||||
|
||||
if ($cityStateZip) {
|
||||
$parts[] = $cityStateZip;
|
||||
}
|
||||
|
||||
return implode("\n", $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get masked bank account number (last 4 digits).
|
||||
*/
|
||||
public function getMaskedAccountNumberAttribute(): ?string
|
||||
{
|
||||
if (! $this->bank_account_number) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return '****'.substr($this->bank_account_number, -4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get masked tax ID (last 4 digits).
|
||||
*/
|
||||
public function getMaskedTaxIdAttribute(): ?string
|
||||
{
|
||||
if (! $this->tax_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return '***-**-'.substr($this->tax_id, -4);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Methods
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Mark this DBA as the default for the business.
|
||||
*/
|
||||
public function markAsDefault(): void
|
||||
{
|
||||
// Clear default from other DBAs for this business
|
||||
static::where('business_id', $this->business_id)
|
||||
->where('id', '!=', $this->id)
|
||||
->update(['is_default' => false]);
|
||||
|
||||
$this->update(['is_default' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display info for invoices/orders.
|
||||
*/
|
||||
public function getDisplayInfo(): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->trade_name,
|
||||
'address' => $this->full_address,
|
||||
'license' => $this->license_number,
|
||||
'logo' => $this->logo_path,
|
||||
'payment_terms' => $this->payment_terms,
|
||||
'payment_instructions' => $this->payment_instructions,
|
||||
'invoice_footer' => $this->invoice_footer,
|
||||
'primary_contact' => [
|
||||
'name' => $this->primary_contact_name,
|
||||
'email' => $this->primary_contact_email,
|
||||
'phone' => $this->primary_contact_phone,
|
||||
],
|
||||
'ap_contact' => [
|
||||
'name' => $this->ap_contact_name,
|
||||
'email' => $this->ap_contact_email,
|
||||
'phone' => $this->ap_contact_phone,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Boot
|
||||
// =========================================================================
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
// Auto-generate slug on creation
|
||||
static::creating(function ($dba) {
|
||||
if (empty($dba->slug)) {
|
||||
$dba->slug = Str::slug($dba->trade_name);
|
||||
|
||||
// Ensure unique
|
||||
$original = $dba->slug;
|
||||
$counter = 1;
|
||||
while (static::withTrashed()->where('slug', $dba->slug)->exists()) {
|
||||
$dba->slug = $original.'-'.$counter++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure only one default per business
|
||||
static::saving(function ($dba) {
|
||||
if ($dba->is_default && $dba->isDirty('is_default')) {
|
||||
static::where('business_id', $dba->business_id)
|
||||
->where('id', '!=', $dba->id ?? 0)
|
||||
->update(['is_default' => false]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace App\Models\Crm;
|
||||
use App\Models\Accounting\ArInvoice;
|
||||
use App\Models\Activity;
|
||||
use App\Models\Business;
|
||||
use App\Models\BusinessDba;
|
||||
use App\Models\BusinessLocation;
|
||||
use App\Models\Contact;
|
||||
use App\Models\Order;
|
||||
@@ -28,6 +29,17 @@ class CrmInvoice extends Model
|
||||
|
||||
protected $table = 'crm_invoices';
|
||||
|
||||
protected static function boot(): void
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function ($invoice) {
|
||||
if (empty($invoice->view_token)) {
|
||||
$invoice->view_token = \Illuminate\Support\Str::random(32);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public const STATUS_DRAFT = 'draft';
|
||||
|
||||
public const STATUS_SENT = 'sent';
|
||||
@@ -44,6 +56,7 @@ class CrmInvoice extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'dba_id',
|
||||
'account_id',
|
||||
'location_id',
|
||||
'contact_id',
|
||||
@@ -99,6 +112,14 @@ class CrmInvoice extends Model
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* The DBA (trade name) used for this invoice.
|
||||
*/
|
||||
public function dba(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(BusinessDba::class, 'dba_id');
|
||||
}
|
||||
|
||||
public function account(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class, 'account_id');
|
||||
@@ -389,4 +410,45 @@ class CrmInvoice extends Model
|
||||
|
||||
return $prefix.str_pad($number, 5, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get seller display information for the invoice.
|
||||
* Prioritizes DBA if set, otherwise falls back to business defaults.
|
||||
*/
|
||||
public function getSellerDisplayInfo(): array
|
||||
{
|
||||
if ($this->dba_id && $this->dba) {
|
||||
return $this->dba->getDisplayInfo();
|
||||
}
|
||||
|
||||
// Fall back to business info
|
||||
$business = $this->business;
|
||||
|
||||
return [
|
||||
'name' => $business->dba_name ?: $business->name,
|
||||
'address' => implode("\n", array_filter([
|
||||
$business->invoice_payable_address ?? $business->physical_address,
|
||||
trim(
|
||||
($business->invoice_payable_city ?? $business->physical_city ?? '').
|
||||
($business->invoice_payable_state ?? $business->physical_state ? ', '.($business->invoice_payable_state ?? $business->physical_state) : '').' '.
|
||||
($business->invoice_payable_zipcode ?? $business->physical_zipcode ?? '')
|
||||
),
|
||||
])),
|
||||
'license' => $business->license_number,
|
||||
'logo' => null,
|
||||
'payment_terms' => null,
|
||||
'payment_instructions' => $business->order_invoice_footer,
|
||||
'invoice_footer' => $business->order_invoice_footer,
|
||||
'primary_contact' => [
|
||||
'name' => trim(($business->primary_contact_first_name ?? '').' '.($business->primary_contact_last_name ?? '')),
|
||||
'email' => $business->primary_contact_email ?? $business->business_email,
|
||||
'phone' => $business->primary_contact_phone ?? $business->business_phone,
|
||||
],
|
||||
'ap_contact' => [
|
||||
'name' => trim(($business->ap_contact_first_name ?? '').' '.($business->ap_contact_last_name ?? '')),
|
||||
'email' => $business->ap_contact_email,
|
||||
'phone' => $business->ap_contact_phone,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ class CrmInvoiceItem extends Model
|
||||
'tax_rate',
|
||||
'tax_amount',
|
||||
'line_total',
|
||||
'item_comment',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@@ -53,13 +54,8 @@ class CrmInvoiceItem extends Model
|
||||
$item->calculateLineTotal();
|
||||
});
|
||||
|
||||
static::saved(function ($item) {
|
||||
$item->invoice->calculateTotals();
|
||||
});
|
||||
|
||||
static::deleted(function ($item) {
|
||||
$item->invoice->calculateTotals();
|
||||
});
|
||||
// NOTE: Invoice totals are recalculated explicitly in the controller after all items are saved
|
||||
// We don't auto-recalculate here to prevent lazy loading violations and duplicate calculations
|
||||
}
|
||||
|
||||
// Relationships
|
||||
|
||||
@@ -28,6 +28,7 @@ class CrmQuoteItem extends Model
|
||||
'tax_rate',
|
||||
'line_total',
|
||||
'sort_order',
|
||||
'item_comment',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@@ -45,13 +46,8 @@ class CrmQuoteItem extends Model
|
||||
$item->calculateLineTotal();
|
||||
});
|
||||
|
||||
static::saved(function ($item) {
|
||||
$item->quote->calculateTotals();
|
||||
});
|
||||
|
||||
static::deleted(function ($item) {
|
||||
$item->quote->calculateTotals();
|
||||
});
|
||||
// NOTE: Quote totals are recalculated explicitly in the controller after all items are saved
|
||||
// We don't auto-recalculate here to prevent lazy loading violations and duplicate calculations
|
||||
}
|
||||
|
||||
// Relationships
|
||||
|
||||
@@ -92,6 +92,12 @@ class CrmThread extends Model
|
||||
'seller_business_id',
|
||||
'thread_type',
|
||||
'order_id',
|
||||
'quote_id',
|
||||
// Buyer-side tracking
|
||||
'is_read_by_buyer',
|
||||
'read_at_by_buyer',
|
||||
'buyer_starred_by',
|
||||
'buyer_archived_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@@ -104,6 +110,15 @@ class CrmThread extends Model
|
||||
'snoozed_until' => 'datetime',
|
||||
'first_response_at' => 'datetime',
|
||||
'currently_viewing_since' => 'datetime',
|
||||
'is_chat_request' => 'boolean',
|
||||
'chat_request_at' => 'datetime',
|
||||
'chat_request_responded_at' => 'datetime',
|
||||
'buyer_context' => 'array',
|
||||
// Buyer-side tracking
|
||||
'is_read_by_buyer' => 'boolean',
|
||||
'read_at_by_buyer' => 'datetime',
|
||||
'buyer_starred_by' => 'array',
|
||||
'buyer_archived_by' => 'array',
|
||||
];
|
||||
|
||||
protected $appends = ['is_snoozed', 'other_viewers'];
|
||||
@@ -333,6 +348,62 @@ class CrmThread extends Model
|
||||
return $query->whereIn('brand_id', $brandIds);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Buyer-side scopes (for buyer inbox/CRM)
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Scope to filter threads for a buyer business.
|
||||
*/
|
||||
public function scopeForBuyerBusiness($query, int $businessId)
|
||||
{
|
||||
return $query->where('buyer_business_id', $businessId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to filter threads that have unread messages for buyer.
|
||||
*/
|
||||
public function scopeHasUnreadForBuyer($query)
|
||||
{
|
||||
return $query->where('is_read', false)
|
||||
->where('last_message_direction', 'inbound'); // inbound = from seller
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to filter threads starred by buyer.
|
||||
*/
|
||||
public function scopeStarredByBuyer($query, int $userId)
|
||||
{
|
||||
return $query->whereJsonContains('buyer_starred_by', $userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to filter threads archived by buyer.
|
||||
*/
|
||||
public function scopeArchivedByBuyer($query, int $userId)
|
||||
{
|
||||
return $query->whereJsonContains('buyer_archived_by', $userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope to filter threads NOT archived by buyer.
|
||||
*/
|
||||
public function scopeNotArchivedByBuyer($query, int $userId)
|
||||
{
|
||||
return $query->where(function ($q) use ($userId) {
|
||||
$q->whereNull('buyer_archived_by')
|
||||
->orWhereJsonDoesntContain('buyer_archived_by', $userId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope for unread messages from buyer's perspective.
|
||||
*/
|
||||
public function scopeUnreadForBuyer($query)
|
||||
{
|
||||
return $query->where('is_read_by_buyer', false);
|
||||
}
|
||||
|
||||
// Accessors
|
||||
|
||||
public function getIsSnoozedAttribute(): bool
|
||||
@@ -511,4 +582,84 @@ class CrmThread extends Model
|
||||
default => 'badge-ghost',
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Buyer-side helper methods
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Mark thread as read for buyer.
|
||||
*/
|
||||
public function markAsReadForBuyer(): void
|
||||
{
|
||||
$this->update([
|
||||
'is_read_by_buyer' => true,
|
||||
'read_at_by_buyer' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle star status for buyer.
|
||||
*/
|
||||
public function toggleStarForBuyer(int $userId): void
|
||||
{
|
||||
$starred = $this->buyer_starred_by ?? [];
|
||||
|
||||
if (in_array($userId, $starred)) {
|
||||
$starred = array_values(array_diff($starred, [$userId]));
|
||||
} else {
|
||||
$starred[] = $userId;
|
||||
}
|
||||
|
||||
$this->update(['buyer_starred_by' => $starred]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if thread is starred by buyer.
|
||||
*/
|
||||
public function isStarredByBuyer(int $userId): bool
|
||||
{
|
||||
return in_array($userId, $this->buyer_starred_by ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive thread for buyer.
|
||||
*/
|
||||
public function archiveForBuyer(int $userId): void
|
||||
{
|
||||
$archived = $this->buyer_archived_by ?? [];
|
||||
|
||||
if (! in_array($userId, $archived)) {
|
||||
$archived[] = $userId;
|
||||
}
|
||||
|
||||
$this->update(['buyer_archived_by' => $archived]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unarchive thread for buyer.
|
||||
*/
|
||||
public function unarchiveForBuyer(int $userId): void
|
||||
{
|
||||
$archived = $this->buyer_archived_by ?? [];
|
||||
$archived = array_values(array_diff($archived, [$userId]));
|
||||
|
||||
$this->update(['buyer_archived_by' => $archived]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get latest message relationship.
|
||||
*/
|
||||
public function latestMessage()
|
||||
{
|
||||
return $this->hasOne(CrmChannelMessage::class, 'thread_id')->latestOfMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get quote relationship.
|
||||
*/
|
||||
public function quote()
|
||||
{
|
||||
return $this->belongsTo(CrmQuote::class, 'quote_id');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ class Order extends Model implements Auditable
|
||||
'tax',
|
||||
'total',
|
||||
'status',
|
||||
'is_ping_pong',
|
||||
'created_by',
|
||||
'workorder_status',
|
||||
'payment_terms',
|
||||
@@ -98,6 +99,7 @@ class Order extends Model implements Auditable
|
||||
'surcharge' => 'decimal:2',
|
||||
'tax' => 'decimal:2',
|
||||
'total' => 'decimal:2',
|
||||
'is_ping_pong' => 'boolean',
|
||||
'workorder_status' => 'decimal:2',
|
||||
'due_date' => 'date',
|
||||
'delivery_window_date' => 'date',
|
||||
|
||||
@@ -31,6 +31,7 @@ class OrderItem extends Model implements Auditable
|
||||
'product_name',
|
||||
'product_sku',
|
||||
'brand_name',
|
||||
'item_comment',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -265,6 +265,14 @@ class Product extends Model implements Auditable
|
||||
return $this->belongsTo(Brand::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* CannaiQ product mappings for this product.
|
||||
*/
|
||||
public function cannaiqMappings(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProductCannaiqMapping::class);
|
||||
}
|
||||
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProductCategory::class, 'category_id');
|
||||
@@ -517,10 +525,18 @@ class Product extends Model implements Auditable
|
||||
|
||||
public function scopeInStock($query)
|
||||
{
|
||||
return $query->whereHas('batches', function ($q) {
|
||||
$q->where('is_active', true)
|
||||
->where('is_quarantined', false)
|
||||
->where('quantity_available', '>', 0);
|
||||
return $query->where(function ($q) {
|
||||
// Unlimited inventory products are always in stock
|
||||
$q->where('inventory_mode', self::INV_UNLIMITED)
|
||||
// Or has available batch inventory (using EXISTS for performance)
|
||||
->orWhereExists(function ($subq) {
|
||||
$subq->select(\DB::raw(1))
|
||||
->from('batches')
|
||||
->whereColumn('batches.product_id', 'products.id')
|
||||
->where('batches.is_active', true)
|
||||
->where('batches.is_quarantined', false)
|
||||
->where('batches.quantity_available', '>', 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -762,9 +778,18 @@ class Product extends Model implements Auditable
|
||||
*/
|
||||
public function getImageUrl(?string $size = null): ?string
|
||||
{
|
||||
// Fall back to brand logo if no product image
|
||||
// Fall back to brand logo at 50% size if no product image
|
||||
if (! $this->image_path) {
|
||||
return $this->brand?->getLogoUrl($size);
|
||||
// Map named sizes to pixel widths, then halve them for logo fallback
|
||||
$sizeMap = [
|
||||
'thumb' => 40, // 50% of 80
|
||||
'small' => 80, // 50% of 160
|
||||
'medium' => 200, // 50% of 400
|
||||
'large' => 400, // 50% of 800
|
||||
];
|
||||
$logoSize = is_numeric($size) ? (int) ($size / 2) : ($sizeMap[$size] ?? null);
|
||||
|
||||
return $this->brand?->getLogoUrl($logoSize);
|
||||
}
|
||||
|
||||
// If no hashid, fall back to direct storage URL (for legacy products)
|
||||
@@ -1039,10 +1064,16 @@ class Product extends Model implements Auditable
|
||||
|
||||
/**
|
||||
* Get product story as sanitized HTML.
|
||||
*
|
||||
* Priority: consumer_long_description > buyer_long_description > long_description > description
|
||||
*/
|
||||
public function getStoryHtmlAttribute(): ?string
|
||||
{
|
||||
$text = $this->long_description ?? $this->description;
|
||||
$text = $this->consumer_long_description
|
||||
?? $this->buyer_long_description
|
||||
?? $this->long_description
|
||||
?? $this->description;
|
||||
|
||||
if (! $text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
36
app/Models/ProductCannaiqMapping.php
Normal file
36
app/Models/ProductCannaiqMapping.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Maps Hub products to CannaiQ products.
|
||||
*
|
||||
* Many-to-many relationship:
|
||||
* - One Hub product can map to multiple CannaiQ products
|
||||
* - One CannaiQ product can map to multiple Hub products
|
||||
*/
|
||||
class ProductCannaiqMapping extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'product_id',
|
||||
'cannaiq_product_id',
|
||||
'cannaiq_product_name',
|
||||
'cannaiq_store_id',
|
||||
'cannaiq_store_name',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'cannaiq_product_id' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* The Hub product this mapping belongs to.
|
||||
*/
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
}
|
||||
194
app/Models/TeamConversation.php
Normal file
194
app/Models/TeamConversation.php
Normal file
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class TeamConversation extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
public const TYPE_DIRECT = 'direct';
|
||||
|
||||
public const TYPE_GROUP = 'group';
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'type',
|
||||
'name',
|
||||
'last_message_preview',
|
||||
'last_message_at',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'last_message_at' => 'datetime',
|
||||
];
|
||||
|
||||
// Relationships
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function participants(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'team_conversation_participants', 'conversation_id', 'user_id')
|
||||
->withPivot(['is_admin', 'last_read_at', 'is_muted', 'is_pinned'])
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function messages(): HasMany
|
||||
{
|
||||
return $this->hasMany(TeamMessage::class, 'conversation_id');
|
||||
}
|
||||
|
||||
// Scopes
|
||||
|
||||
public function scopeForUser($query, int $userId)
|
||||
{
|
||||
return $query->whereHas('participants', fn ($q) => $q->where('user_id', $userId));
|
||||
}
|
||||
|
||||
public function scopeForBusiness($query, int $businessId)
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
// Helper Methods
|
||||
|
||||
/**
|
||||
* Get or create a direct conversation between two users
|
||||
*/
|
||||
public static function getOrCreateDirect(int $businessId, int $userId1, int $userId2): self
|
||||
{
|
||||
// Find existing direct conversation between these two users
|
||||
$conversation = self::where('business_id', $businessId)
|
||||
->where('type', self::TYPE_DIRECT)
|
||||
->whereHas('participants', fn ($q) => $q->where('user_id', $userId1))
|
||||
->whereHas('participants', fn ($q) => $q->where('user_id', $userId2))
|
||||
->first();
|
||||
|
||||
if ($conversation) {
|
||||
return $conversation;
|
||||
}
|
||||
|
||||
// Create new direct conversation
|
||||
$conversation = self::create([
|
||||
'business_id' => $businessId,
|
||||
'type' => self::TYPE_DIRECT,
|
||||
'created_by' => $userId1,
|
||||
]);
|
||||
|
||||
// Add both participants
|
||||
$conversation->participants()->attach([
|
||||
$userId1 => ['is_admin' => false],
|
||||
$userId2 => ['is_admin' => false],
|
||||
]);
|
||||
|
||||
return $conversation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the other participant in a direct conversation
|
||||
*/
|
||||
public function getOtherParticipant(int $currentUserId): ?User
|
||||
{
|
||||
if ($this->type !== self::TYPE_DIRECT) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->participants->firstWhere('id', '!=', $currentUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display name for the conversation
|
||||
*/
|
||||
public function getDisplayName(int $currentUserId): string
|
||||
{
|
||||
if ($this->type === self::TYPE_GROUP) {
|
||||
return $this->name ?? 'Group Chat';
|
||||
}
|
||||
|
||||
$other = $this->getOtherParticipant($currentUserId);
|
||||
|
||||
return $other ? $other->name : 'Unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has unread messages
|
||||
*/
|
||||
public function hasUnreadFor(int $userId): bool
|
||||
{
|
||||
$participant = $this->participants->firstWhere('id', $userId);
|
||||
|
||||
if (! $participant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$lastRead = $participant->pivot->last_read_at;
|
||||
|
||||
if (! $lastRead) {
|
||||
return $this->messages()->exists();
|
||||
}
|
||||
|
||||
return $this->messages()
|
||||
->where('created_at', '>', $lastRead)
|
||||
->where('sender_id', '!=', $userId)
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unread count for user
|
||||
*/
|
||||
public function getUnreadCountFor(int $userId): int
|
||||
{
|
||||
$participant = $this->participants->firstWhere('id', $userId);
|
||||
|
||||
if (! $participant) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$lastRead = $participant->pivot->last_read_at;
|
||||
|
||||
$query = $this->messages()->where('sender_id', '!=', $userId);
|
||||
|
||||
if ($lastRead) {
|
||||
$query->where('created_at', '>', $lastRead);
|
||||
}
|
||||
|
||||
return $query->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark conversation as read for user
|
||||
*/
|
||||
public function markReadFor(int $userId): void
|
||||
{
|
||||
$this->participants()->updateExistingPivot($userId, [
|
||||
'last_read_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last message info
|
||||
*/
|
||||
public function updateLastMessage(TeamMessage $message): void
|
||||
{
|
||||
$this->update([
|
||||
'last_message_preview' => \Str::limit($message->body, 100),
|
||||
'last_message_at' => $message->created_at,
|
||||
]);
|
||||
}
|
||||
}
|
||||
107
app/Models/TeamMessage.php
Normal file
107
app/Models/TeamMessage.php
Normal file
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Events\TeamMessageSent;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class TeamMessage extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
public const TYPE_TEXT = 'text';
|
||||
|
||||
public const TYPE_FILE = 'file';
|
||||
|
||||
public const TYPE_IMAGE = 'image';
|
||||
|
||||
public const TYPE_SYSTEM = 'system';
|
||||
|
||||
protected $fillable = [
|
||||
'conversation_id',
|
||||
'sender_id',
|
||||
'body',
|
||||
'type',
|
||||
'metadata',
|
||||
'read_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'metadata' => 'array',
|
||||
'read_by' => 'array',
|
||||
];
|
||||
|
||||
// Relationships
|
||||
|
||||
public function conversation(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(TeamConversation::class, 'conversation_id');
|
||||
}
|
||||
|
||||
public function sender(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'sender_id');
|
||||
}
|
||||
|
||||
// Events
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::created(function (TeamMessage $message) {
|
||||
// Update conversation's last message
|
||||
$message->conversation->updateLastMessage($message);
|
||||
|
||||
// Broadcast to other participants
|
||||
broadcast(new TeamMessageSent($message))->toOthers();
|
||||
});
|
||||
}
|
||||
|
||||
// Helper Methods
|
||||
|
||||
/**
|
||||
* Mark message as read by user
|
||||
*/
|
||||
public function markReadBy(int $userId): void
|
||||
{
|
||||
$readBy = $this->read_by ?? [];
|
||||
|
||||
if (! in_array($userId, $readBy)) {
|
||||
$readBy[] = $userId;
|
||||
$this->update(['read_by' => $readBy]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if message has been read by user
|
||||
*/
|
||||
public function isReadBy(int $userId): bool
|
||||
{
|
||||
return in_array($userId, $this->read_by ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sender's display name
|
||||
*/
|
||||
public function getSenderName(): string
|
||||
{
|
||||
return $this->sender?->name ?? 'Unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sender's initials
|
||||
*/
|
||||
public function getSenderInitials(): string
|
||||
{
|
||||
if (! $this->sender) {
|
||||
return '?';
|
||||
}
|
||||
|
||||
$firstName = $this->sender->first_name ?? '';
|
||||
$lastName = $this->sender->last_name ?? '';
|
||||
|
||||
return strtoupper(substr($firstName, 0, 1).substr($lastName, 0, 1)) ?: '?';
|
||||
}
|
||||
}
|
||||
68
app/Notifications/CrmNewMessageNotification.php
Normal file
68
app/Notifications/CrmNewMessageNotification.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Crm\CrmChannelMessage;
|
||||
use App\Models\Crm\CrmThread;
|
||||
use App\Services\NotificationStyleService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class CrmNewMessageNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/**
|
||||
* Create a new notification instance.
|
||||
*/
|
||||
public function __construct(
|
||||
protected CrmChannelMessage $message,
|
||||
protected CrmThread $thread
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
// Database only - no email for each message
|
||||
return ['database'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the array representation of the notification.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
$business = $this->thread->business;
|
||||
$businessSlug = $business?->slug ?? 'default';
|
||||
$contact = $this->thread->contact;
|
||||
$contactName = $contact?->getFullName() ?? 'Unknown';
|
||||
$channelType = $this->message->channel_type;
|
||||
$preview = \Illuminate\Support\Str::limit($this->message->body, 50);
|
||||
|
||||
$style = NotificationStyleService::getStyle('message');
|
||||
|
||||
return [
|
||||
'type' => 'crm_message',
|
||||
'title' => 'New Message',
|
||||
'message' => "{$contactName} via {$channelType}: {$preview}",
|
||||
'action_url' => route('seller.business.crm.inbox', $businessSlug),
|
||||
'action_text' => 'View Inbox',
|
||||
'icon' => $style['icon'],
|
||||
'color' => $style['color'],
|
||||
'meta' => [
|
||||
'thread_id' => $this->thread->id,
|
||||
'message_id' => $this->message->id,
|
||||
'channel_type' => $channelType,
|
||||
'contact_id' => $contact?->id,
|
||||
'contact_name' => $contactName,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
164
app/Services/BannerAdService.php
Normal file
164
app/Services/BannerAdService.php
Normal file
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\BannerAdStatus;
|
||||
use App\Enums\BannerAdZone;
|
||||
use App\Models\BannerAd;
|
||||
use App\Models\BannerAdEvent;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class BannerAdService
|
||||
{
|
||||
/**
|
||||
* Get active ads for a zone, optionally filtered by user context
|
||||
*/
|
||||
public function getAdsForZone(
|
||||
BannerAdZone $zone,
|
||||
?string $businessType = null,
|
||||
int $limit = 10
|
||||
): Collection {
|
||||
$cacheKey = "banner_ads:{$zone->value}:".($businessType ?? 'all');
|
||||
|
||||
return Cache::remember($cacheKey, 300, function () use ($zone, $businessType) {
|
||||
$query = BannerAd::active()
|
||||
->forZone($zone)
|
||||
->forBusinessType($businessType)
|
||||
->orderByDesc('priority')
|
||||
->orderByDesc('weight');
|
||||
|
||||
return $query->get();
|
||||
})->take($limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single ad for display with weighted random selection
|
||||
*/
|
||||
public function getAdForZone(
|
||||
BannerAdZone $zone,
|
||||
?string $businessType = null
|
||||
): ?BannerAd {
|
||||
$ads = $this->getAdsForZone($zone, $businessType, 10);
|
||||
|
||||
if ($ads->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If only one ad, return it
|
||||
if ($ads->count() === 1) {
|
||||
return $ads->first();
|
||||
}
|
||||
|
||||
// Weighted random selection
|
||||
$totalWeight = $ads->sum('weight');
|
||||
$random = rand(1, $totalWeight);
|
||||
$currentWeight = 0;
|
||||
|
||||
foreach ($ads as $ad) {
|
||||
$currentWeight += $ad->weight;
|
||||
if ($random <= $currentWeight) {
|
||||
return $ad;
|
||||
}
|
||||
}
|
||||
|
||||
return $ads->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an impression event (fire-and-forget)
|
||||
*/
|
||||
public function recordImpression(BannerAd $ad, array $context = []): void
|
||||
{
|
||||
// Fire-and-forget to avoid blocking page load
|
||||
dispatch(function () use ($ad, $context) {
|
||||
BannerAdEvent::create([
|
||||
'banner_ad_id' => $ad->id,
|
||||
'business_id' => $context['business_id'] ?? null,
|
||||
'user_id' => $context['user_id'] ?? null,
|
||||
'event_type' => 'impression',
|
||||
'session_id' => $context['session_id'] ?? session()->getId(),
|
||||
'ip_address' => $context['ip_address'] ?? request()->ip(),
|
||||
'user_agent' => $context['user_agent'] ?? request()->userAgent(),
|
||||
'page_url' => $context['page_url'] ?? request()->fullUrl(),
|
||||
'referer' => $context['referer'] ?? request()->header('referer'),
|
||||
]);
|
||||
|
||||
$ad->incrementImpressions();
|
||||
})->afterResponse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a click event
|
||||
*/
|
||||
public function recordClick(BannerAd $ad, array $context = []): void
|
||||
{
|
||||
BannerAdEvent::create([
|
||||
'banner_ad_id' => $ad->id,
|
||||
'business_id' => $context['business_id'] ?? null,
|
||||
'user_id' => $context['user_id'] ?? null,
|
||||
'event_type' => 'click',
|
||||
'session_id' => $context['session_id'] ?? session()->getId(),
|
||||
'ip_address' => $context['ip_address'] ?? request()->ip(),
|
||||
'user_agent' => $context['user_agent'] ?? request()->userAgent(),
|
||||
'page_url' => $context['page_url'] ?? null,
|
||||
'referer' => $context['referer'] ?? request()->header('referer'),
|
||||
]);
|
||||
|
||||
$ad->incrementClicks();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache for a zone
|
||||
*/
|
||||
public function clearZoneCache(BannerAdZone $zone): void
|
||||
{
|
||||
Cache::forget("banner_ads:{$zone->value}:all");
|
||||
Cache::forget("banner_ads:{$zone->value}:buyer");
|
||||
Cache::forget("banner_ads:{$zone->value}:seller");
|
||||
Cache::forget("banner_ads:{$zone->value}:both");
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all banner ad caches
|
||||
*/
|
||||
public function clearAllCaches(): void
|
||||
{
|
||||
foreach (BannerAdZone::cases() as $zone) {
|
||||
$this->clearZoneCache($zone);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update ad statuses based on schedule
|
||||
*
|
||||
* @return int Number of ads updated
|
||||
*/
|
||||
public function updateScheduledStatuses(): int
|
||||
{
|
||||
$now = now();
|
||||
$updated = 0;
|
||||
|
||||
// Activate scheduled ads that have started
|
||||
$updated += BannerAd::where('status', BannerAdStatus::SCHEDULED)
|
||||
->whereNotNull('starts_at')
|
||||
->where('starts_at', '<=', $now)
|
||||
->where(function ($q) use ($now) {
|
||||
$q->whereNull('ends_at')->orWhere('ends_at', '>', $now);
|
||||
})
|
||||
->update(['status' => BannerAdStatus::ACTIVE]);
|
||||
|
||||
// Expire active ads that have ended
|
||||
$updated += BannerAd::where('status', BannerAdStatus::ACTIVE)
|
||||
->whereNotNull('ends_at')
|
||||
->where('ends_at', '<', $now)
|
||||
->update(['status' => BannerAdStatus::EXPIRED]);
|
||||
|
||||
// Clear caches if any ads were updated
|
||||
if ($updated > 0) {
|
||||
$this->clearAllCaches();
|
||||
}
|
||||
|
||||
return $updated;
|
||||
}
|
||||
}
|
||||
@@ -124,6 +124,9 @@ class CrmChannelService
|
||||
// Broadcast incoming message for real-time updates
|
||||
broadcast(new CrmThreadMessageSent($message->fresh(['attachments', 'user']), $thread));
|
||||
|
||||
// Send notification to assigned user or business users
|
||||
$this->notifyUsersOfNewMessage($message, $thread);
|
||||
|
||||
// Trigger automations
|
||||
app(CrmAutomationService::class)->trigger('message_received', [
|
||||
'business_id' => $businessId,
|
||||
@@ -767,4 +770,31 @@ class CrmChannelService
|
||||
default => 'crm',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify users of a new inbound message
|
||||
*/
|
||||
protected function notifyUsersOfNewMessage(CrmChannelMessage $message, CrmThread $thread): void
|
||||
{
|
||||
$notification = new \App\Notifications\CrmNewMessageNotification($message, $thread);
|
||||
|
||||
// If thread is assigned to a specific user, notify them
|
||||
if ($thread->assigned_to) {
|
||||
$assignee = \App\Models\User::find($thread->assigned_to);
|
||||
if ($assignee) {
|
||||
$assignee->notify($notification);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise notify all business users (up to 5 to avoid spam)
|
||||
$business = $thread->business;
|
||||
if ($business) {
|
||||
$users = $business->users()->limit(5)->get();
|
||||
foreach ($users as $user) {
|
||||
$user->notify($notification);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,11 +50,17 @@ class InboundEmailService
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse plus addressing (inbox+u123@domain.com => user ID 123)
|
||||
$assignToUserId = $this->parseUserFromPlusAddress($toEmail);
|
||||
|
||||
// Strip plus address for identity lookup
|
||||
$baseEmail = $this->stripPlusAddress($toEmail);
|
||||
|
||||
// 1) Resolve business + email identity
|
||||
$identity = BusinessEmailIdentity::findByEmail($toEmail);
|
||||
$identity = BusinessEmailIdentity::findByEmail($baseEmail);
|
||||
|
||||
if (! $identity) {
|
||||
Log::info("InboundEmailService: No identity found for {$toEmail}");
|
||||
Log::info("InboundEmailService: No identity found for {$baseEmail}");
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -77,13 +83,26 @@ class InboundEmailService
|
||||
// 4) Find or create thread (using In-Reply-To / Message-ID / contact)
|
||||
$thread = $this->findOrCreateThread($business, $channel, $contact, $payload);
|
||||
|
||||
// 5) Store the inbound message
|
||||
// 5) Auto-assign thread if plus address specified a user
|
||||
if ($assignToUserId && ! $thread->assigned_to) {
|
||||
// Verify user belongs to this business
|
||||
$user = \App\Models\User::find($assignToUserId);
|
||||
if ($user && $user->businesses()->where('businesses.id', $business->id)->exists()) {
|
||||
$thread->update(['assigned_to' => $assignToUserId]);
|
||||
Log::info("InboundEmailService: Auto-assigned thread {$thread->id} to user {$assignToUserId} via plus address");
|
||||
}
|
||||
}
|
||||
|
||||
// 6) Store the inbound message
|
||||
$message = $this->storeInboundMessage($thread, $channel, $contact, $payload);
|
||||
|
||||
// 6) Update identity last received timestamp
|
||||
// 7) Update identity last received timestamp
|
||||
$identity->recordReceived();
|
||||
|
||||
// 7) Trigger automations
|
||||
// 8) Send notification to users
|
||||
$this->notifyUsersOfNewMessage($message, $thread);
|
||||
|
||||
// 9) Trigger automations
|
||||
app(CrmAutomationService::class)->trigger('message_received', [
|
||||
'business_id' => $business->id,
|
||||
'message' => $message,
|
||||
@@ -97,6 +116,33 @@ class InboundEmailService
|
||||
return $message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse user ID from plus address.
|
||||
* inbox+u123@domain.com => 123
|
||||
*/
|
||||
protected function parseUserFromPlusAddress(string $email): ?int
|
||||
{
|
||||
if (! str_contains($email, '+')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract the part between + and @
|
||||
if (preg_match('/\+u(\d+)@/', $email, $matches)) {
|
||||
return (int) $matches[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip plus address from email.
|
||||
* inbox+u123@domain.com => inbox@domain.com
|
||||
*/
|
||||
protected function stripPlusAddress(string $email): string
|
||||
{
|
||||
return preg_replace('/\+[^@]+@/', '@', $email);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find or create a contact from the sender's email.
|
||||
*/
|
||||
@@ -338,4 +384,31 @@ class InboundEmailService
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify users of a new inbound email message.
|
||||
*/
|
||||
protected function notifyUsersOfNewMessage(CrmChannelMessage $message, CrmThread $thread): void
|
||||
{
|
||||
$notification = new \App\Notifications\CrmNewMessageNotification($message, $thread);
|
||||
|
||||
// If thread is assigned to a specific user, notify them
|
||||
if ($thread->assigned_to) {
|
||||
$assignee = \App\Models\User::find($thread->assigned_to);
|
||||
if ($assignee) {
|
||||
$assignee->notify($notification);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise notify all business users (up to 5 to avoid spam)
|
||||
$business = $thread->business;
|
||||
if ($business) {
|
||||
$users = $business->users()->limit(5)->get();
|
||||
foreach ($users as $user) {
|
||||
$user->notify($notification);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,12 @@ class NotificationStyleService
|
||||
'icon' => 'bell',
|
||||
],
|
||||
|
||||
// Messages - Info Blue
|
||||
'message', 'chat', 'inbox' => [
|
||||
'color' => 'primary',
|
||||
'icon' => 'message-circle',
|
||||
],
|
||||
|
||||
// Success - Green
|
||||
'success', 'approved', 'completed', 'achievement' => [
|
||||
'color' => 'success',
|
||||
|
||||
135
app/Services/ProductComparisonService.php
Normal file
135
app/Services/ProductComparisonService.php
Normal file
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Product;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Service for managing product comparison list.
|
||||
*
|
||||
* Stores selected product IDs in the session for side-by-side comparison.
|
||||
*/
|
||||
class ProductComparisonService
|
||||
{
|
||||
private const SESSION_KEY = 'compare_products';
|
||||
|
||||
private const MAX_ITEMS = 4; // Maximum products to compare at once
|
||||
|
||||
/**
|
||||
* Add a product to comparison list.
|
||||
*/
|
||||
public function add(int $productId): bool
|
||||
{
|
||||
$ids = $this->getProductIds();
|
||||
|
||||
if (count($ids) >= self::MAX_ITEMS) {
|
||||
return false; // List is full
|
||||
}
|
||||
|
||||
if (in_array($productId, $ids)) {
|
||||
return true; // Already in list
|
||||
}
|
||||
|
||||
$ids[] = $productId;
|
||||
session()->put(self::SESSION_KEY, $ids);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a product from comparison list.
|
||||
*/
|
||||
public function remove(int $productId): void
|
||||
{
|
||||
$ids = $this->getProductIds();
|
||||
$ids = array_values(array_filter($ids, fn ($id) => $id !== $productId));
|
||||
session()->put(self::SESSION_KEY, $ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a product in the comparison list.
|
||||
*
|
||||
* @return array{added: bool, count: int}
|
||||
*/
|
||||
public function toggle(int $productId): array
|
||||
{
|
||||
if ($this->isInList($productId)) {
|
||||
$this->remove($productId);
|
||||
|
||||
return ['added' => false, 'count' => $this->count()];
|
||||
}
|
||||
|
||||
$added = $this->add($productId);
|
||||
|
||||
return ['added' => $added, 'count' => $this->count()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a product is in the comparison list.
|
||||
*/
|
||||
public function isInList(int $productId): bool
|
||||
{
|
||||
return in_array($productId, $this->getProductIds());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all product IDs in comparison list.
|
||||
*/
|
||||
public function getProductIds(): array
|
||||
{
|
||||
return session()->get(self::SESSION_KEY, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get products with full model data for comparison.
|
||||
*/
|
||||
public function getProducts(): Collection
|
||||
{
|
||||
$ids = $this->getProductIds();
|
||||
|
||||
if (empty($ids)) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return Product::query()
|
||||
->with(['brand:id,name,slug', 'strain:id,name,type', 'category:id,name'])
|
||||
->whereIn('id', $ids)
|
||||
->where('is_active', true)
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the comparison list.
|
||||
*/
|
||||
public function clear(): void
|
||||
{
|
||||
session()->forget(self::SESSION_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of products in comparison list.
|
||||
*/
|
||||
public function count(): int
|
||||
{
|
||||
return count($this->getProductIds());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if list is full.
|
||||
*/
|
||||
public function isFull(): bool
|
||||
{
|
||||
return $this->count() >= self::MAX_ITEMS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get maximum allowed items.
|
||||
*/
|
||||
public function maxItems(): int
|
||||
{
|
||||
return self::MAX_ITEMS;
|
||||
}
|
||||
}
|
||||
111
app/Services/RecentlyViewedService.php
Normal file
111
app/Services/RecentlyViewedService.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Product;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Service for tracking and retrieving recently viewed products.
|
||||
*
|
||||
* Stores product IDs in the session with timestamps, limited to the most recent 20 products.
|
||||
*/
|
||||
class RecentlyViewedService
|
||||
{
|
||||
private const SESSION_KEY = 'recently_viewed_products';
|
||||
|
||||
private const MAX_ITEMS = 20;
|
||||
|
||||
/**
|
||||
* Record a product view.
|
||||
*/
|
||||
public function recordView(int $productId): void
|
||||
{
|
||||
$viewed = session()->get(self::SESSION_KEY, []);
|
||||
|
||||
// Remove if already exists (we'll re-add with new timestamp)
|
||||
$viewed = array_filter($viewed, fn ($item) => $item['id'] !== $productId);
|
||||
|
||||
// Add to beginning of array
|
||||
array_unshift($viewed, [
|
||||
'id' => $productId,
|
||||
'viewed_at' => now()->timestamp,
|
||||
]);
|
||||
|
||||
// Limit to max items
|
||||
$viewed = array_slice($viewed, 0, self::MAX_ITEMS);
|
||||
|
||||
session()->put(self::SESSION_KEY, $viewed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recently viewed product IDs (most recent first).
|
||||
*
|
||||
* @param int|null $limit Limit results (default: all)
|
||||
* @param int|null $excludeId Exclude a specific product ID
|
||||
*/
|
||||
public function getProductIds(?int $limit = null, ?int $excludeId = null): array
|
||||
{
|
||||
$viewed = session()->get(self::SESSION_KEY, []);
|
||||
|
||||
if ($excludeId) {
|
||||
$viewed = array_filter($viewed, fn ($item) => $item['id'] !== $excludeId);
|
||||
}
|
||||
|
||||
$ids = array_column(array_values($viewed), 'id');
|
||||
|
||||
if ($limit) {
|
||||
$ids = array_slice($ids, 0, $limit);
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recently viewed products with full model data.
|
||||
*
|
||||
* @param int $limit Maximum number of products to return
|
||||
* @param int|null $excludeId Exclude a specific product ID (e.g., current product)
|
||||
*/
|
||||
public function getProducts(int $limit = 8, ?int $excludeId = null): Collection
|
||||
{
|
||||
$ids = $this->getProductIds($limit + 1, $excludeId); // Get extra to handle filter
|
||||
|
||||
if (empty($ids)) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
// Fetch products in the order they were viewed
|
||||
$products = Product::query()
|
||||
->with('brand:id,name,slug')
|
||||
->whereIn('id', $ids)
|
||||
->where('is_active', true)
|
||||
->get();
|
||||
|
||||
// Sort by the original order and limit
|
||||
$idOrder = array_flip($ids);
|
||||
|
||||
return $products
|
||||
->sortBy(fn ($p) => $idOrder[$p->id] ?? PHP_INT_MAX)
|
||||
->take($limit)
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear recently viewed history.
|
||||
*/
|
||||
public function clear(): void
|
||||
{
|
||||
session()->forget(self::SESSION_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of recently viewed products.
|
||||
*/
|
||||
public function count(): int
|
||||
{
|
||||
return count(session()->get(self::SESSION_KEY, []));
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,7 @@ class SuiteMenuResolver
|
||||
'connect_conversations' => [
|
||||
'label' => 'Conversations',
|
||||
'icon' => 'heroicon-o-chat-bubble-left-right',
|
||||
'route' => 'seller.business.crm.threads.index',
|
||||
'route' => 'seller.business.crm.inbox',
|
||||
'section' => 'Connect',
|
||||
'order' => 20,
|
||||
],
|
||||
@@ -143,7 +143,7 @@ class SuiteMenuResolver
|
||||
'crm_inbox' => [
|
||||
'label' => 'Conversations',
|
||||
'icon' => 'heroicon-o-inbox-stack',
|
||||
'route' => 'seller.business.crm.threads.index',
|
||||
'route' => 'seller.business.crm.inbox',
|
||||
'section' => 'Connect',
|
||||
'order' => 22,
|
||||
],
|
||||
|
||||
63
app/View/Components/BannerAd.php
Normal file
63
app/View/Components/BannerAd.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\View\Components;
|
||||
|
||||
use App\Enums\BannerAdZone;
|
||||
use App\Models\BannerAd as BannerAdModel;
|
||||
use App\Services\BannerAdService;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\View\Component;
|
||||
|
||||
class BannerAd extends Component
|
||||
{
|
||||
public ?BannerAdModel $ad = null;
|
||||
|
||||
public BannerAdZone $zone;
|
||||
|
||||
public array $dimensions;
|
||||
|
||||
public function __construct(
|
||||
string $zone,
|
||||
) {
|
||||
$this->zone = BannerAdZone::from($zone);
|
||||
$this->dimensions = $this->zone->dimensions();
|
||||
|
||||
// Skip if banner_ads table doesn't exist (migrations not run)
|
||||
if (! Schema::hasTable('banner_ads')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get business type from authenticated user
|
||||
$businessType = auth()->user()?->user_type;
|
||||
|
||||
// Get ad from service
|
||||
$service = app(BannerAdService::class);
|
||||
$this->ad = $service->getAdForZone($this->zone, $businessType);
|
||||
|
||||
// Record impression if ad found
|
||||
if ($this->ad) {
|
||||
$service->recordImpression($this->ad, [
|
||||
'business_id' => auth()->user()?->businesses->first()?->id,
|
||||
'user_id' => auth()->id(),
|
||||
]);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Log but don't break the page if banner ad system has issues
|
||||
Log::warning('BannerAd component error: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function shouldRender(): bool
|
||||
{
|
||||
return $this->ad !== null;
|
||||
}
|
||||
|
||||
public function render(): View|Closure|string
|
||||
{
|
||||
return view('components.banner-ad');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* Fix crm_invoices schema:
|
||||
* - Make account_id nullable (controller allows null for standalone invoices)
|
||||
* - Make location_id nullable (added via separate migration)
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('crm_invoices', function (Blueprint $table) {
|
||||
// Drop the foreign key constraint first
|
||||
$table->dropForeign(['account_id']);
|
||||
});
|
||||
|
||||
Schema::table('crm_invoices', function (Blueprint $table) {
|
||||
// Make account_id nullable
|
||||
$table->foreignId('account_id')->nullable()->change();
|
||||
|
||||
// Re-add foreign key with nullOnDelete
|
||||
$table->foreign('account_id')->references('id')->on('businesses')->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('crm_invoices', function (Blueprint $table) {
|
||||
$table->dropForeign(['account_id']);
|
||||
});
|
||||
|
||||
Schema::table('crm_invoices', function (Blueprint $table) {
|
||||
$table->foreignId('account_id')->nullable(false)->change();
|
||||
$table->foreign('account_id')->references('id')->on('businesses')->cascadeOnDelete();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* Fix: last_name should be nullable to match controller validation
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('contacts', function (Blueprint $table) {
|
||||
$table->string('last_name')->nullable()->change();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('contacts', function (Blueprint $table) {
|
||||
$table->string('last_name')->nullable(false)->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Team conversations (1:1 or group chats between coworkers)
|
||||
Schema::create('team_conversations', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('business_id')->constrained()->onDelete('cascade');
|
||||
$table->string('type')->default('direct'); // direct, group
|
||||
$table->string('name')->nullable(); // For group chats
|
||||
$table->text('last_message_preview')->nullable();
|
||||
$table->timestamp('last_message_at')->nullable();
|
||||
$table->foreignId('created_by')->constrained('users')->onDelete('cascade');
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->index(['business_id', 'last_message_at']);
|
||||
});
|
||||
|
||||
// Participants in team conversations
|
||||
Schema::create('team_conversation_participants', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('conversation_id')->constrained('team_conversations')->onDelete('cascade');
|
||||
$table->foreignId('user_id')->constrained()->onDelete('cascade');
|
||||
$table->boolean('is_admin')->default(false); // For group chats
|
||||
$table->timestamp('last_read_at')->nullable();
|
||||
$table->boolean('is_muted')->default(false);
|
||||
$table->boolean('is_pinned')->default(false);
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['conversation_id', 'user_id']);
|
||||
$table->index(['user_id', 'last_read_at']);
|
||||
});
|
||||
|
||||
// Messages in team conversations
|
||||
Schema::create('team_messages', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('conversation_id')->constrained('team_conversations')->onDelete('cascade');
|
||||
$table->foreignId('sender_id')->constrained('users')->onDelete('cascade');
|
||||
$table->text('body');
|
||||
$table->string('type')->default('text'); // text, file, image, system
|
||||
$table->json('metadata')->nullable(); // For attachments, reactions, etc.
|
||||
$table->json('read_by')->nullable(); // Array of user_ids who have read
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->index(['conversation_id', 'created_at']);
|
||||
});
|
||||
|
||||
// Add chat_request fields to crm_threads for buyer-initiated chats
|
||||
Schema::table('crm_threads', function (Blueprint $table) {
|
||||
$table->boolean('is_chat_request')->default(false)->after('thread_type');
|
||||
$table->string('chat_request_status')->nullable()->after('is_chat_request'); // pending, accepted, declined
|
||||
$table->timestamp('chat_request_at')->nullable()->after('chat_request_status');
|
||||
$table->foreignId('chat_request_accepted_by')->nullable()->after('chat_request_at')->constrained('users')->nullOnDelete();
|
||||
$table->timestamp('chat_request_responded_at')->nullable()->after('chat_request_accepted_by');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('crm_threads', function (Blueprint $table) {
|
||||
$table->dropForeign(['chat_request_accepted_by']);
|
||||
$table->dropColumn([
|
||||
'is_chat_request',
|
||||
'chat_request_status',
|
||||
'chat_request_at',
|
||||
'chat_request_accepted_by',
|
||||
'chat_request_responded_at',
|
||||
]);
|
||||
});
|
||||
|
||||
Schema::dropIfExists('team_messages');
|
||||
Schema::dropIfExists('team_conversation_participants');
|
||||
Schema::dropIfExists('team_conversations');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('crm_threads', function (Blueprint $table) {
|
||||
// Buyer context - captured when chat is initiated
|
||||
$table->json('buyer_context')->nullable()->after('chat_request_responded_at');
|
||||
// Stores: current_page, current_product_id, cart_items, recent_products, referrer, etc.
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('crm_threads', function (Blueprint $table) {
|
||||
$table->dropColumn('buyer_context');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('business_dbas', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('business_id')->constrained('businesses')->onDelete('cascade');
|
||||
|
||||
// Identity
|
||||
$table->string('trade_name');
|
||||
$table->string('slug')->unique();
|
||||
|
||||
// Address
|
||||
$table->string('address')->nullable();
|
||||
$table->string('address_line_2')->nullable();
|
||||
$table->string('city')->nullable();
|
||||
$table->string('state', 2)->nullable();
|
||||
$table->string('zip', 10)->nullable();
|
||||
|
||||
// License
|
||||
$table->string('license_number')->nullable();
|
||||
$table->string('license_type')->nullable();
|
||||
$table->date('license_expiration')->nullable();
|
||||
|
||||
// Bank Info (encrypted at model level)
|
||||
$table->string('bank_name')->nullable();
|
||||
$table->string('bank_account_name')->nullable();
|
||||
$table->text('bank_routing_number')->nullable();
|
||||
$table->text('bank_account_number')->nullable();
|
||||
$table->string('bank_account_type', 50)->nullable();
|
||||
|
||||
// Tax
|
||||
$table->text('tax_id')->nullable();
|
||||
$table->string('tax_id_type', 50)->nullable();
|
||||
|
||||
// Contacts
|
||||
$table->string('primary_contact_name')->nullable();
|
||||
$table->string('primary_contact_email')->nullable();
|
||||
$table->string('primary_contact_phone', 50)->nullable();
|
||||
$table->string('ap_contact_name')->nullable();
|
||||
$table->string('ap_contact_email')->nullable();
|
||||
$table->string('ap_contact_phone', 50)->nullable();
|
||||
|
||||
// Invoice Settings
|
||||
$table->string('payment_terms', 50)->nullable();
|
||||
$table->text('payment_instructions')->nullable();
|
||||
$table->text('invoice_footer')->nullable();
|
||||
$table->string('invoice_prefix', 10)->nullable();
|
||||
|
||||
// Branding
|
||||
$table->string('logo_path')->nullable();
|
||||
$table->jsonb('brand_colors')->nullable();
|
||||
|
||||
// Status
|
||||
$table->boolean('is_default')->default(false);
|
||||
$table->boolean('is_active')->default(true);
|
||||
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
// Indexes
|
||||
$table->index('business_id');
|
||||
$table->index(['business_id', 'is_default']);
|
||||
$table->index('is_active');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('business_dbas');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('crm_invoices', function (Blueprint $table) {
|
||||
$table->foreignId('dba_id')
|
||||
->nullable()
|
||||
->after('business_id')
|
||||
->constrained('business_dbas')
|
||||
->onDelete('set null');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('crm_invoices', function (Blueprint $table) {
|
||||
$table->dropForeign(['dba_id']);
|
||||
$table->dropColumn('dba_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Add item_comment to invoice line items
|
||||
if (Schema::hasTable('crm_invoice_items') && !Schema::hasColumn('crm_invoice_items', 'item_comment')) {
|
||||
Schema::table('crm_invoice_items', function (Blueprint $table) {
|
||||
$table->text('item_comment')->nullable()->after('discount_percent');
|
||||
});
|
||||
}
|
||||
|
||||
// Add item_comment to quote line items
|
||||
if (Schema::hasTable('crm_quote_items') && !Schema::hasColumn('crm_quote_items', 'item_comment')) {
|
||||
Schema::table('crm_quote_items', function (Blueprint $table) {
|
||||
$table->text('item_comment')->nullable()->after('discount_percent');
|
||||
});
|
||||
}
|
||||
|
||||
// Add item_comment to order items (if not exists)
|
||||
if (Schema::hasTable('order_items') && !Schema::hasColumn('order_items', 'item_comment')) {
|
||||
Schema::table('order_items', function (Blueprint $table) {
|
||||
$table->text('item_comment')->nullable()->after('notes');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (Schema::hasColumn('crm_invoice_items', 'item_comment')) {
|
||||
Schema::table('crm_invoice_items', function (Blueprint $table) {
|
||||
$table->dropColumn('item_comment');
|
||||
});
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('crm_quote_items', 'item_comment')) {
|
||||
Schema::table('crm_quote_items', function (Blueprint $table) {
|
||||
$table->dropColumn('item_comment');
|
||||
});
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('order_items', 'item_comment')) {
|
||||
Schema::table('order_items', function (Blueprint $table) {
|
||||
$table->dropColumn('item_comment');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('businesses', function (Blueprint $table) {
|
||||
$table->boolean('ping_pong_enabled')->default(false)->after('is_enterprise_plan');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('businesses', function (Blueprint $table) {
|
||||
$table->dropColumn('ping_pong_enabled');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('orders', function (Blueprint $table) {
|
||||
$table->boolean('is_ping_pong')->default(false)->after('status');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('orders', function (Blueprint $table) {
|
||||
$table->dropColumn('is_ping_pong');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Fix crm_invoices and crm_invoice_items schema issues.
|
||||
*
|
||||
* Issues:
|
||||
* 1. crm_invoices: missing location_id column (controller tries to insert it)
|
||||
* 2. crm_invoice_items: 'name' column is NOT NULL but controller doesn't provide it
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Add location_id to crm_invoices if it doesn't exist
|
||||
if (! Schema::hasColumn('crm_invoices', 'location_id')) {
|
||||
Schema::table('crm_invoices', function (Blueprint $table) {
|
||||
$table->foreignId('location_id')->nullable()->after('account_id')->constrained('locations')->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
// Make name nullable in crm_invoice_items
|
||||
Schema::table('crm_invoice_items', function (Blueprint $table) {
|
||||
$table->string('name')->nullable()->change();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (Schema::hasColumn('crm_invoices', 'location_id')) {
|
||||
Schema::table('crm_invoices', function (Blueprint $table) {
|
||||
$table->dropForeign(['location_id']);
|
||||
$table->dropColumn('location_id');
|
||||
});
|
||||
}
|
||||
|
||||
Schema::table('crm_invoice_items', function (Blueprint $table) {
|
||||
$table->string('name')->nullable(false)->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Add cannaiq_brand_key column to brands table.
|
||||
*
|
||||
* This stores the normalized brand name used to query CannaIQ API.
|
||||
* Example: "Aloha TymeMachine" → "alohatymemachine"
|
||||
*
|
||||
* Security: This key is used to filter ALL CannaIQ API calls to only
|
||||
* return data for this brand. Brands cannot see competitor data.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('brands', function (Blueprint $table) {
|
||||
$table->string('cannaiq_brand_key')->nullable()->after('inbound_email_channel_id');
|
||||
$table->index('cannaiq_brand_key');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('brands', function (Blueprint $table) {
|
||||
$table->dropIndex(['cannaiq_brand_key']);
|
||||
$table->dropColumn('cannaiq_brand_key');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('banner_ads', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
// Ownership: null = platform-wide, brand_id = brand-specific ad
|
||||
$table->foreignId('brand_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->foreignId('created_by_user_id')->constrained('users');
|
||||
|
||||
// Content
|
||||
$table->string('name'); // Internal name for admin
|
||||
$table->string('headline')->nullable(); // Overlay headline
|
||||
$table->text('description')->nullable(); // Overlay description
|
||||
$table->string('cta_text', 50)->nullable(); // Button text (e.g., "Shop Now")
|
||||
$table->string('cta_url', 500); // Click destination URL
|
||||
|
||||
// Image - stored in MinIO
|
||||
$table->string('image_path'); // Full MinIO path
|
||||
$table->string('image_alt')->nullable(); // Alt text for accessibility
|
||||
|
||||
// Placement & Dimensions
|
||||
$table->string('zone', 50)->index(); // BannerAdZone enum value
|
||||
|
||||
// Scheduling
|
||||
$table->timestamp('starts_at')->nullable();
|
||||
$table->timestamp('ends_at')->nullable();
|
||||
|
||||
// Targeting
|
||||
$table->json('target_business_types')->nullable(); // ['buyer', 'seller', 'both']
|
||||
$table->boolean('is_platform_wide')->default(true);
|
||||
|
||||
// Status
|
||||
$table->string('status', 20)->default('draft'); // draft, active, scheduled, paused, expired
|
||||
|
||||
// Priority for rotation (higher = shown more often)
|
||||
$table->integer('priority')->default(0);
|
||||
$table->integer('weight')->default(100); // For weighted random selection (1-1000)
|
||||
|
||||
// Stats (denormalized for fast reads)
|
||||
$table->unsignedBigInteger('impressions')->default(0);
|
||||
$table->unsignedBigInteger('clicks')->default(0);
|
||||
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
// Indexes
|
||||
$table->index(['zone', 'status']);
|
||||
$table->index(['status', 'starts_at', 'ends_at']);
|
||||
$table->index(['brand_id', 'status']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('banner_ads');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('banner_ad_events', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
$table->foreignId('banner_ad_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('business_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
|
||||
|
||||
$table->string('event_type', 20)->index(); // impression, click
|
||||
|
||||
// Context
|
||||
$table->string('session_id', 100)->index();
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
$table->text('user_agent')->nullable();
|
||||
$table->string('page_url', 500)->nullable(); // Where ad was shown
|
||||
$table->string('referer', 500)->nullable();
|
||||
|
||||
$table->timestamp('created_at')->index();
|
||||
|
||||
// Indexes for reporting
|
||||
$table->index(['banner_ad_id', 'event_type', 'created_at']);
|
||||
$table->index(['created_at', 'event_type']); // For daily rollups
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('banner_ad_events');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('banner_ad_daily_stats', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
$table->foreignId('banner_ad_id')->constrained()->cascadeOnDelete();
|
||||
$table->date('date')->index();
|
||||
|
||||
$table->unsignedInteger('impressions')->default(0);
|
||||
$table->unsignedInteger('clicks')->default(0);
|
||||
$table->unsignedInteger('unique_impressions')->default(0);
|
||||
$table->unsignedInteger('unique_clicks')->default(0);
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['banner_ad_id', 'date']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('banner_ad_daily_stats');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('crm_threads', function (Blueprint $table) {
|
||||
// Buyer-side read tracking (separate from seller-side is_read)
|
||||
$table->boolean('is_read_by_buyer')->default(true)->after('is_read');
|
||||
$table->timestamp('read_at_by_buyer')->nullable()->after('is_read_by_buyer');
|
||||
|
||||
// Buyer-side star/archive (JSON arrays of user IDs)
|
||||
$table->jsonb('buyer_starred_by')->nullable()->after('read_at_by_buyer');
|
||||
$table->jsonb('buyer_archived_by')->nullable()->after('buyer_starred_by');
|
||||
|
||||
// Quote relationship
|
||||
$table->foreignId('quote_id')->nullable()->after('order_id')->constrained('crm_quotes')->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('crm_threads', function (Blueprint $table) {
|
||||
$table->dropConstrainedForeignId('quote_id');
|
||||
$table->dropColumn([
|
||||
'is_read_by_buyer',
|
||||
'read_at_by_buyer',
|
||||
'buyer_starred_by',
|
||||
'buyer_archived_by',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Create product_cannaiq_mappings pivot table.
|
||||
*
|
||||
* Maps Hub products to CannaiQ products (many-to-many).
|
||||
* - One Hub product can map to multiple CannaiQ products (same product at different dispensaries)
|
||||
* - One CannaiQ product can map to multiple Hub products (bundles, variants)
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('product_cannaiq_mappings', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('product_id')->constrained()->onDelete('cascade');
|
||||
$table->bigInteger('cannaiq_product_id'); // CannaiQ product ID
|
||||
$table->string('cannaiq_product_name'); // Denormalized for display
|
||||
$table->string('cannaiq_store_id')->nullable(); // Optional store-specific mapping
|
||||
$table->string('cannaiq_store_name')->nullable(); // Denormalized store name
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['product_id', 'cannaiq_product_id'], 'product_cannaiq_unique');
|
||||
$table->index('cannaiq_product_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('product_cannaiq_mappings');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Merge duplicate parent categories - keep the one with more products,
|
||||
* reassign products and children from the duplicate.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Pairs: [keep, delete] - keep the one with more products
|
||||
$merges = [
|
||||
// Accessories: both have 0 products, keep lower ID
|
||||
[41, 86],
|
||||
// Concentrates: 16 has 15, 61 has 1
|
||||
[16, 61],
|
||||
// Tinctures: 37 has 7, 82 has 0
|
||||
[37, 82],
|
||||
// Vapes: 56 has 157, 11 has 0
|
||||
[56, 11],
|
||||
// Pre-Rolls: 52 has 3, 7 has 0
|
||||
[52, 7],
|
||||
// Edibles: 70 has 6, 25 has 3
|
||||
[70, 25],
|
||||
// Flower: both have 0, keep lower ID
|
||||
[1, 46],
|
||||
// Topicals: 32 has 34, 77 has 4
|
||||
[32, 77],
|
||||
];
|
||||
|
||||
foreach ($merges as [$keepId, $deleteId]) {
|
||||
// Move products from duplicate to keeper
|
||||
DB::table('products')
|
||||
->where('category_id', $deleteId)
|
||||
->update(['category_id' => $keepId]);
|
||||
|
||||
// Move child categories from duplicate to keeper
|
||||
DB::table('product_categories')
|
||||
->where('parent_id', $deleteId)
|
||||
->update(['parent_id' => $keepId]);
|
||||
|
||||
// Soft delete the duplicate (or hard delete if no soft deletes)
|
||||
DB::table('product_categories')
|
||||
->where('id', $deleteId)
|
||||
->update(['is_active' => false]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
// Re-activate the deactivated categories
|
||||
$reactivate = [86, 61, 82, 11, 7, 25, 46, 77];
|
||||
|
||||
DB::table('product_categories')
|
||||
->whereIn('id', $reactivate)
|
||||
->update(['is_active' => true]);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Assign White Label Canna products to Bulk category and mark as raw materials.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// White Label Canna brand ID = 18, Bulk category ID = 147
|
||||
$brandId = 18;
|
||||
$bulkCategoryId = 147;
|
||||
|
||||
// Update all products from this brand
|
||||
DB::table('products')
|
||||
->where('brand_id', $brandId)
|
||||
->update([
|
||||
'category_id' => $bulkCategoryId,
|
||||
'is_raw_material' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
// Revert - set back to no category and not raw material
|
||||
$brandId = 18;
|
||||
|
||||
DB::table('products')
|
||||
->where('brand_id', $brandId)
|
||||
->update([
|
||||
'category_id' => null,
|
||||
'is_raw_material' => false,
|
||||
]);
|
||||
}
|
||||
};
|
||||
@@ -103,4 +103,4 @@ Most likely categories:
|
||||
|
||||
- [INVENTORY_REFACTORING_NEEDED.md](./INVENTORY_REFACTORING_NEEDED.md)
|
||||
- [MODULE_FEATURE_TIERS.md](./MODULE_FEATURE_TIERS.md)
|
||||
- [PR #53](https://code.cannabrands.app/Cannabrands/hub/pulls/53)
|
||||
- [PR #53](https://git.spdy.io/Cannabrands/hub/pulls/53)
|
||||
|
||||
165
docs/WIREGUARD_VPN.md
Normal file
165
docs/WIREGUARD_VPN.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# WireGuard VPN Access
|
||||
|
||||
Access the Spdy.io internal network (10.100.0.0/16) from anywhere.
|
||||
|
||||
## Server Details
|
||||
|
||||
| Setting | Value |
|
||||
|---------|-------|
|
||||
| Endpoint | 185.149.70.86:51820 |
|
||||
| Server Public Key | `1RjA1Y79htmW/PJdjlWLuJWEhAimUvZ1asvZPBgTJz4=` |
|
||||
| VPN Network | 10.200.0.0/24 |
|
||||
| Internal Network | 10.100.0.0/16 |
|
||||
|
||||
## Client Configs
|
||||
|
||||
Save any config below as `spdy-vpn.conf` and import into WireGuard.
|
||||
|
||||
### Client 1 (10.200.0.10)
|
||||
|
||||
```ini
|
||||
[Interface]
|
||||
PrivateKey = gAqMAKIMa7J0nsEf3aufQk2EDUCD6EZp7rWrL+KIbVE=
|
||||
Address = 10.200.0.10/32
|
||||
DNS = 1.1.1.1
|
||||
|
||||
[Peer]
|
||||
PublicKey = 1RjA1Y79htmW/PJdjlWLuJWEhAimUvZ1asvZPBgTJz4=
|
||||
Endpoint = 185.149.70.86:51820
|
||||
AllowedIPs = 10.100.0.0/16, 10.200.0.0/24
|
||||
PersistentKeepalive = 25
|
||||
```
|
||||
|
||||
### Client 2 (10.200.0.11)
|
||||
|
||||
```ini
|
||||
[Interface]
|
||||
PrivateKey = UGJ+AqvLgChJbR4Ddsabqwu4GkhrpLlQjy42EQtd6UQ=
|
||||
Address = 10.200.0.11/32
|
||||
DNS = 1.1.1.1
|
||||
|
||||
[Peer]
|
||||
PublicKey = 1RjA1Y79htmW/PJdjlWLuJWEhAimUvZ1asvZPBgTJz4=
|
||||
Endpoint = 185.149.70.86:51820
|
||||
AllowedIPs = 10.100.0.0/16, 10.200.0.0/24
|
||||
PersistentKeepalive = 25
|
||||
```
|
||||
|
||||
### Client 3 (10.200.0.12)
|
||||
|
||||
```ini
|
||||
[Interface]
|
||||
PrivateKey = mFl7sW4Tu0mmfQl5e8giBMXPIoM+2/7GFlbncRoAPGk=
|
||||
Address = 10.200.0.12/32
|
||||
DNS = 1.1.1.1
|
||||
|
||||
[Peer]
|
||||
PublicKey = 1RjA1Y79htmW/PJdjlWLuJWEhAimUvZ1asvZPBgTJz4=
|
||||
Endpoint = 185.149.70.86:51820
|
||||
AllowedIPs = 10.100.0.0/16, 10.200.0.0/24
|
||||
PersistentKeepalive = 25
|
||||
```
|
||||
|
||||
### Client 4 (10.200.0.13)
|
||||
|
||||
```ini
|
||||
[Interface]
|
||||
PrivateKey = QGMDVy/VXCRVRZU09shvCPNaNaNy35rKhV5a0KbdM3o=
|
||||
Address = 10.200.0.13/32
|
||||
DNS = 1.1.1.1
|
||||
|
||||
[Peer]
|
||||
PublicKey = 1RjA1Y79htmW/PJdjlWLuJWEhAimUvZ1asvZPBgTJz4=
|
||||
Endpoint = 185.149.70.86:51820
|
||||
AllowedIPs = 10.100.0.0/16, 10.200.0.0/24
|
||||
PersistentKeepalive = 25
|
||||
```
|
||||
|
||||
### Client 5 (10.200.0.14) - Kelly's Workstation
|
||||
|
||||
```ini
|
||||
[Interface]
|
||||
PrivateKey = yLyDJUaQJzjGvtNbNWGhfKRh9AVvLl4sUy+/b1Fdikk=
|
||||
Address = 10.200.0.14/32
|
||||
DNS = 1.1.1.1
|
||||
|
||||
[Peer]
|
||||
PublicKey = 1RjA1Y79htmW/PJdjlWLuJWEhAimUvZ1asvZPBgTJz4=
|
||||
Endpoint = 185.149.70.86:51820
|
||||
AllowedIPs = 10.100.0.0/16, 10.200.0.0/24
|
||||
PersistentKeepalive = 25
|
||||
```
|
||||
|
||||
**Client Public Key:** `9SQT0qHwOzc9S+SdODPrmJAYEz1MK1zLZqZZeyWdtDc=`
|
||||
**Config Location:** `/etc/wireguard/wg0.conf`
|
||||
|
||||
## Client Assignments
|
||||
|
||||
| Client | IP | Assigned To | Public Key |
|
||||
|--------|-----|-------------|------------|
|
||||
| 1 | 10.200.0.10 | Available | - |
|
||||
| 2 | 10.200.0.11 | Available | - |
|
||||
| 3 | 10.200.0.12 | Available | - |
|
||||
| 4 | 10.200.0.13 | Available | - |
|
||||
| 5 | 10.200.0.14 | Kelly's Workstation | `9SQT0qHwOzc9S+SdODPrmJAYEz1MK1zLZqZZeyWdtDc=` |
|
||||
|
||||
## How to Connect
|
||||
|
||||
### Linux
|
||||
|
||||
```bash
|
||||
# Install
|
||||
sudo apt install wireguard
|
||||
|
||||
# Save config
|
||||
sudo nano /etc/wireguard/spdy-vpn.conf
|
||||
# Paste one of the configs above
|
||||
|
||||
# Connect
|
||||
sudo wg-quick up spdy-vpn
|
||||
|
||||
# Disconnect
|
||||
sudo wg-quick down spdy-vpn
|
||||
|
||||
# Auto-start on boot
|
||||
sudo systemctl enable wg-quick@spdy-vpn
|
||||
```
|
||||
|
||||
### macOS / Windows / iOS / Android
|
||||
|
||||
1. Install WireGuard from App Store / Google Play / [wireguard.com](https://wireguard.com/install/)
|
||||
2. Click "Add Tunnel" → "Create from file" or paste config
|
||||
3. Toggle ON to connect
|
||||
|
||||
## Test Connection
|
||||
|
||||
```bash
|
||||
ping 10.200.0.1 # VPN gateway
|
||||
ping 10.100.9.70 # CI server
|
||||
ping 10.100.6.50 # Database (db1)
|
||||
ping 10.100.9.60 # Monitoring
|
||||
```
|
||||
|
||||
## Internal Network Reference
|
||||
|
||||
| IP | Host | Service |
|
||||
|----|------|---------|
|
||||
| 10.100.6.10 | k8s-cp1 | K8s Control Plane |
|
||||
| 10.100.7.10 | k8s-cp2 | K8s Control Plane |
|
||||
| 10.100.8.10 | k8s-cp3 | K8s Control Plane |
|
||||
| 10.100.6.40 | k8s-worker1 | K8s Worker |
|
||||
| 10.100.7.40 | k8s-worker2 | K8s Worker |
|
||||
| 10.100.8.40 | k8s-worker3 | K8s Worker |
|
||||
| 10.100.9.40 | k8s-worker4 | K8s Worker |
|
||||
| 10.100.6.50 | db1 | PostgreSQL Primary |
|
||||
| 10.100.7.50 | db2 | PostgreSQL Secondary |
|
||||
| 10.100.9.50 | redis1 | Redis + Reverb |
|
||||
| 10.100.9.60 | monitoring | Grafana + Prometheus |
|
||||
| 10.100.9.70 | ci | Woodpecker CI + WireGuard |
|
||||
| 10.100.9.80 | minio | MinIO S3 Storage |
|
||||
| 10.100.8.60 | lb2 | Load Balancer (cannaiq.co) |
|
||||
|
||||
## Server Location
|
||||
|
||||
WireGuard server runs on: `10.100.9.70` (CI server)
|
||||
Config: `/etc/wireguard/wg0.conf`
|
||||
@@ -926,7 +926,7 @@ spec:
|
||||
spec:
|
||||
containers:
|
||||
- name: migrate
|
||||
image: code.cannabrands.app/cannabrands/hub:2025.10.4
|
||||
image: git.spdy.io/cannabrands/hub:2025.10.4
|
||||
command: ["php", "artisan", "migrate", "--force"]
|
||||
env:
|
||||
- name: DB_HOST
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the automated cleanup policy for our Gitea Docker registry (`code.cannabrands.app/cannabrands/hub`). The cleanup rules automatically manage image retention to prevent unlimited storage growth while preserving important versions.
|
||||
This document describes the automated cleanup policy for our Gitea Docker registry (`git.spdy.io/cannabrands/hub`). The cleanup rules automatically manage image retention to prevent unlimited storage growth while preserving important versions.
|
||||
|
||||
## Retention Policy
|
||||
|
||||
@@ -50,7 +50,7 @@ This document describes the automated cleanup policy for our Gitea Docker regist
|
||||
|
||||
### Gitea UI Location
|
||||
|
||||
1. Log in to `https://code.cannabrands.app`
|
||||
1. Log in to `https://git.spdy.io`
|
||||
2. Navigate to your organization: `Cannabrands`
|
||||
3. Go to **Settings** → **Packages** → **Cleanup Rules**
|
||||
4. Click **Add Cleanup Rule**
|
||||
@@ -152,7 +152,7 @@ Check current registry usage:
|
||||
2. View "Storage Used" metric
|
||||
|
||||
# Via Docker registry API (if available)
|
||||
curl -u username:token https://code.cannabrands.app/v2/_catalog
|
||||
curl -u username:token https://git.spdy.io/v2/_catalog
|
||||
```
|
||||
|
||||
### 3. List Current Images
|
||||
@@ -161,10 +161,10 @@ Check what's currently in the registry:
|
||||
|
||||
```bash
|
||||
# List all tags for the hub repository
|
||||
curl -s https://code.cannabrands.app/v2/cannabrands/hub/tags/list | jq .
|
||||
curl -s https://git.spdy.io/v2/cannabrands/hub/tags/list | jq .
|
||||
|
||||
# Count tags by type
|
||||
curl -s https://code.cannabrands.app/v2/cannabrands/hub/tags/list | \
|
||||
curl -s https://git.spdy.io/v2/cannabrands/hub/tags/list | \
|
||||
jq -r '.tags[]' | grep -c "^sha-" # Count SHA tags
|
||||
```
|
||||
|
||||
@@ -219,8 +219,8 @@ Unfortunately, once Gitea deletes an image, it's **permanent**. Prevention strat
|
||||
To rebuild from tag:
|
||||
```bash
|
||||
git checkout 2025.10.1
|
||||
docker build -t code.cannabrands.app/cannabrands/hub:2025.10.1 .
|
||||
docker push code.cannabrands.app/cannabrands/hub:2025.10.1
|
||||
docker build -t git.spdy.io/cannabrands/hub:2025.10.1 .
|
||||
docker push git.spdy.io/cannabrands/hub:2025.10.1
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user