Compare commits

...

88 Commits

Author SHA1 Message Date
kelly
eb8e2a89c4 fix: update Short Description field in product content tab
- Rename 'Short & Tagline' section to 'Short Description'
- Change from input to textarea (rows=6) to match consumer description size
- Update character hint to '200-300 characters recommended' (no validation)
2025-12-11 13:42:26 -07:00
kelly
8286aebf4e fix: remove is_primary from contact forms and displays
- Remove 'Set as Primary Contact' checkbox from add/edit modals
- Remove 'Primary' badge from contact lists and dropdowns
- Update column header from 'Primary Contact' to 'Contact'
- Remove is_primary from validation rules and controller logic
- Remove delete danger zone from contact edit page
- Contacts now show first contact instead of filtering by is_primary
2025-12-11 13:42:26 -07:00
kelly
4cff4af841 fix: remove delete button from contact edit page 2025-12-11 13:41:48 -07:00
kelly
8abcd3291e fix(#200): use hashid instead of id for batch edit/update routes
The batch edit form was using $batch->id for the form action and
QR code endpoints. Since Batch uses HasHashid trait, these should
use $batch->hashid for proper route model binding.
2025-12-11 13:41:48 -07:00
kelly
a7c3eb4183 Merge pull request 'fix: Gitea issues batch - #190, #194, #195, #197' (#199) from fix/gitea-issues-batch into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/199
2025-12-11 18:03:08 +00:00
kelly
1ed62fe0de fix(#190): wrap product creation in transaction with proper error handling
If image upload fails, the product creation will now be rolled back.
Added explicit check for storeAs() failure to provide clearer error.
2025-12-11 10:35:03 -07:00
kelly
160b312ca5 fix(#195, #197): handle checkbox fields in contact create/update
Checkboxes don't send values when unchecked. Use request->boolean()
to properly handle is_primary and is_active fields, defaulting to
false when not present in the request.
2025-12-11 10:33:44 -07:00
kelly
6d22a99259 Merge pull request 'fix: make seller table pages responsive' (#198) from fix/responsive-tables into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/198
2025-12-11 17:33:41 +00:00
kelly
febfd75016 fix(#194): use hashid instead of id for batch edit/activate URLs
The Batch model uses HasHashid trait which binds routes by hashid,
but the Alpine.js links were using batch.id. Fixed to use batch.hashid.

Also added hashid to the batch data passed to Alpine.js.
2025-12-11 10:21:14 -07:00
kelly
fbb72f902b Merge pull request 'feat: add server-side search to all index pages' (#196) from feature/server-side-search into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/196
2025-12-11 17:16:21 +00:00
kelly
fd11ae0fe0 feat: add mobile responsiveness to tables and sidebar
- Fix sidebar collapse CSS
- Remove sidebar checkbox name attributes
- Add responsive hidden columns for:
  - accounts index
  - automations index
  - leads index
  - invoices create
  - marketing contacts index
  - products index
2025-12-11 10:07:52 -07:00
kelly
16c5c455fa fix: make CRM Accounts page responsive
- Header stacks vertically on mobile
- Hide Primary Contact and Status columns on xs screens
- Hide Orders column on sm screens
- Hide Open Opps column on md screens
2025-12-11 10:05:54 -07:00
kelly
df587fdda3 fix: make Orders and Invoices pages responsive
- Orders: hide Date, Items, Fulfillment columns on mobile
- Invoices: hide Customer, Invoice Date, Due Date on mobile
- Headers stack vertically on mobile with proper spacing
- Essential columns visible on all screen sizes
2025-12-11 10:04:27 -07:00
kelly
3fb5747aa2 feat: add server-side search to all index pages
Standardize search functionality across all listing pages:
- Products, Contacts, Quotes, Tasks, Leads, Accounts, Invoices, Orders

All pages now use simple form-based server-side search:
- Type search term, press Enter or click magnifying glass
- Full database search (not limited to current page)
- Removed confusing live-search dropdowns that only searched current page
- Added JSON response support for AJAX requests in controllers

Updated filter-bar component to support alpine mode with optional
server-side search on Enter key press.
2025-12-11 10:01:35 -07:00
kelly
33c9420b00 Merge pull request 'fix: UI standardization and sidebar improvements' (#193) from fix/ui-standardization-and-sidebar into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/193
2025-12-11 15:09:58 +00:00
kelly
37204edfd7 fix: UI standardization and sidebar improvements
- Fix sidebar menu items requiring double-click (checkbox overlay issue)
- Remove jittery scroll animation on sidebar navigation
- Remove green search button, use neutral icon instead
- Standardize dropdown menus across all pages (btn instead of label, menu-sm)
- Fix brand dashboard topPerformer undefined key error
- Fix product hashid validation for image routes
- Initialize Alpine.js search state from URL params on products page
- Update theme colors in tailwind config
2025-12-11 01:37:41 -07:00
kelly
8d9725b501 fix: replace inline SVGs with HTML entities in CannaiQ settings
SVG icons were rendering at full page size due to missing size
constraints. Replaced with HTML character entities instead.
2025-12-11 00:09:25 -07:00
kelly
6cf8ad1854 feat(admin): add CannaiQ settings page under Integrations
- Add CannaiQ page to sidebar navigation under Integrations group
- Shows connection status (API key configured or trusted origin)
- Displays available features (Brand Analysis, Intelligence, Promos)
- Shows environment variable configuration
- Includes Test Connection and Clear Cache buttons
- Documents how to enable CannaiQ per-business
2025-12-11 00:07:06 -07:00
kelly
58f787feb0 feat(admin): add Integrations tab with CannaiQ section
- Move CannaiQ settings from Suites tab to new Integrations tab
- Add feature list placeholder explaining CannaiQ capabilities
- Add link to CannaiQ website for more information
2025-12-11 00:02:38 -07:00
kelly
970ce05846 Merge pull request 'feat: Brand Analysis page + Crystal bug fixes (#176, #180, #182)' (#186) from fix/brand-analysis-404 into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/186
2025-12-11 05:33:54 +00:00
kelly
672b0d5f6b chore: re-trigger CI 2025-12-10 22:21:11 -07:00
kelly
4415194b28 fix: require CannaiQ enabled for Brand Analysis, show connection errors
- Block access to Brand Analysis page when CannaiQ is disabled
  - Show analysis-disabled.blade.php with feature info and contact support CTA
  - Add checks to analysis() and analysisRefresh() controller methods

- Add connectionError property to BrandAnalysisDTO
  - When CannaiQ is enabled but API fails, show error instead of silent fallback
  - cannaiqEnabled stays true (feature IS enabled, just API unavailable)

- Update analysis.blade.php to display connection errors
  - Red 'Connection Error' badge in header when API fails
  - Alert banner with error message and 'Contact Support' link
  - Users can see the issue clearly and know to contact support
2025-12-10 22:21:11 -07:00
kelly
213b0ef8f2 feat: add cannaiq_enabled check to brand analysis endpoints 2025-12-10 22:21:11 -07:00
kelly
13dbe046e1 fix: add missing CannaiQ brand analytics API methods
BrandAnalysisService calls getBrandMetrics(), getBrandCompetitors(),
getBrandPromoMetrics(), and getBrandSlippage() methods that were not
defined in CannaiqClient. These v1.5 brand analytics endpoints enable:

- Whitespace and regional penetration data
- Competitor head-to-head comparisons
- Promotion velocity lift metrics
- Slippage/churn detection

All methods return graceful error responses if the API endpoints
don't exist yet, allowing the service to fall back to basic analysis.
2025-12-10 22:21:11 -07:00
kelly
592df4de44 fix: resolve Crystal issues #176, #180, #182
Issue #176: Products - Pricing Not Listed
- Cast wholesale_price to float when building product listings JSON
- PostgreSQL numeric columns return strings, breaking JS `.toFixed(2)`
- Fixed in ProductController index() and listings() methods

Issue #180: Quotes - New Customer Not in Dropdown
- Removed `whereHas('contacts')` filter from account query
- Newly created customers without contacts were being excluded
- Added `where('status', 'approved')` filter instead

Issue #182: Invoices - Search Not Working
- Added search/status parameter handling to index() method
- Search filters by invoice number or customer business name
- Status filters by unpaid/paid/overdue
- Added withQueryString() to pagination for filter persistence
2025-12-10 22:21:11 -07:00
kelly
ae581b4d5c Merge pull request 'fix: Crystal issues batch 2 - products, batches, images' (#192) from fix/crystal-issues-batch-2 into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/192
2025-12-11 05:10:15 +00:00
kelly
8a8f83cc0c feat: backfill product/brand descriptions from MySQL
- Remove min/max validation from tagline, description, long_description
- Add migration to import long_description from product_extras
- Restore 8 soft-deleted Nuvata products
- Update 13 brands with tagline/description/long_description from MySQL
2025-12-10 21:38:06 -07:00
kelly
722904d487 fix: Crystal issues batch 2 - products, batches, images
- Fix #190: Product image upload now uses MinIO (default disk) with proper
  path structure: businesses/{slug}/brands/{slug}/products/{sku}/images/

- Fix #176: Products index now uses effective_price accessor instead of
  just wholesale_price, so sale prices display correctly

- Fix #163: Batch create page was referencing non-existent 'component'
  relationship - changed to 'product' which is the actual relationship
2025-12-10 21:37:24 -07:00
kelly
ddc84f6730 Merge pull request 'fix(#187): Customer Edit/Delete Contact Errors' (#191) from fix/crystal-customer-edits-187 into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/191
2025-12-11 02:55:03 +00:00
kelly
2c510844f0 Merge pull request 'feat: Brand Analysis v4 + fix #172 brand dashboard products tab' (#181) from fix/brand-analysis-v4-fixes into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/181
2025-12-11 02:03:53 +00:00
kelly
105a1e8ce0 Merge pull request 'fix(#182): add search and status filtering to invoice index' (#189) from fix/invoice-search-182 into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/189
2025-12-11 02:02:53 +00:00
kelly
7e06ff3488 fix(#187): use contact hashid for route parameters instead of id
Contact model uses HasHashid trait which sets getRouteKeyName() to 'hashid'.
Routes expecting {contact} parameter require hashid, not numeric id.

Fixed in:
- contacts-edit.blade.php: update and destroy form actions
- contacts/index.blade.php: destroy form action in archive dropdown
2025-12-10 18:57:38 -07:00
kelly
aed1e62c65 style: fix Pint issues in brand analysis files
- single_quote fixes
- unary_operator_spaces fixes
- concat_space fixes
2025-12-10 18:36:36 -07:00
kelly
f9f1b8dc46 Merge pull request 'fix: resolve Crystal issues #161 and #180 (Quotes customer dropdown)' (#188) from fix/crystal-issues-2 into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/188
2025-12-11 01:34:22 +00:00
kelly
89d3a54988 fix(#182): add search and status filtering to invoice index
- Added search by invoice number or customer business name (case-insensitive)
- Added status filter (unpaid/paid/overdue)
- Added withQueryString() to preserve filters during pagination
2025-12-10 18:33:58 -07:00
kelly
0c60e5c519 fix(#161): show all approved buyers in quotes customer dropdown
The quote create form was filtering accounts by whereHas('contacts'),
which excluded newly created buyer businesses that don't have contacts
yet. Changed to filter by status='approved' instead, allowing contacts
to be added after selecting the account.

This also fixes #180 (new customer not in quotes dropdown).
2025-12-10 18:19:29 -07:00
kelly
1ecc4a916b Merge pull request 'fix: case-insensitive product search and hashid defensive checks' (#179) from fix/product-search-case-insensitive into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/179
2025-12-11 00:49:35 +00:00
kelly
f9d7573cb4 chore: re-trigger CI 2025-12-10 17:28:17 -07:00
kelly
e48e9c9b82 Merge pull request 'feat: add build date/time to sidebar version display' (#184) from feat/sidebar-build-date into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/184
2025-12-11 00:19:56 +00:00
kelly
afbb1ba79c Merge pull request 'fix(ci): use explicit git clone plugin for auth' (#185) from fix/ci-git-auth into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/185
2025-12-11 00:16:59 +00:00
kelly
5f0042e483 fix(ci): use explicit git clone plugin for auth
The default Woodpecker clone was failing with:
'could not read Username for https://code.cannabrands.app'

Using woodpeckerci/plugin-git explicitly should use the
configured repo credentials from Woodpecker's Gitea OAuth.
2025-12-10 17:08:12 -07:00
kelly
08f5a3adac feat: add build date/time to sidebar version display
- Add appBuildDate variable from AppServiceProvider
- In local dev: shows commit date (e.g., 'Dec 10, 2:30pm')
- In production: reads BUILD_DATE from version.env
- Updated all sidebars: seller-suites, buyer, seller-legacy, brand-portal
- Updated Filament admin footer
2025-12-10 16:49:56 -07:00
kelly
e62ea5c809 fix: correct Blade syntax error in Promo Performance section
- Wrap promo table in @if(!empty($promosList)) check
- Add proper @else block for empty state message
- Fixes ParseError: unexpected token endif at line 1254
2025-12-10 16:17:21 -07:00
kelly
8d43953cad fix(#172): add defensive hashid filtering for brand products tab
Products without hashids would cause route generation errors.
Added whereNotNull('hashid') to query and filter() to collection.
2025-12-10 16:16:29 -07:00
kelly
a628f2b207 feat: add v3 comparables, supporting signals, and improve slippage display 2025-12-10 16:07:33 -07:00
kelly
367daadfe9 feat: add Brand Analysis page with CannaiQ intelligence
- Add BrandAnalysisService for market intelligence data
- Add BrandAnalysisDTO for structured analysis data
- Add AdvancedV3IntelligenceService for advanced metrics
- Add analysis(), analysisRefresh(), storePlaybook() to BrandController
- Add brand analysis routes
- Add analysis.blade.php view with:
  - Retail partner placement metrics
  - Competitor landscape analysis
  - Inventory projection alerts
  - Promo performance tracking
  - Slippage/action required alerts
  - V3 market signals and shelf opportunities
2025-12-10 15:40:38 -07:00
kelly
329c01523a Merge pull request 'fix: resolve Crystal issues #162, #163, #165, #166' (#171) from fix/crystal-issues-162-166 into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/171
2025-12-10 22:29:15 +00:00
kelly
5fb26f901d Merge pull request 'fix: resolve Crystal issues #167, #172, #173' (#174) from fix/gitea-critical-issues into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/174
2025-12-10 22:29:02 +00:00
kelly
6baadf5744 fix: resolve Crystal issues #167, #172, #173
Issue #167: Invoice - No products populate for manual invoice
- Added scopeForBusiness() to Product model
- SearchController::invoiceProducts() now works correctly

Issue #172: Brand Overview Links Broken
- Eager load products relation in BrandController::dashboard()
- Fixes N+1 query and ensures metrics display correctly

Issue #173: Products Search only searching current page
- Changed filter-bar from Alpine-only to server-side form submission
- Search now queries entire database, not just current page
2025-12-10 15:14:14 -07:00
kelly
a3508c57a2 fix: resolve Crystal issues #162, #163, #165, #166
Issue #162: Edit Contact 404 error
- Contacts missing hashids caused 404 on edit (hashid-based routing)
- Migration backfills hashids for all 153 existing contacts

Issue #163: Batch creation error
- Added missing quantity_unit column to batches table
- Added hashid column to batches table
- Updated Batch model with HasHashid trait and quantity_unit in fillable

Issue #165: No save button on product edit
- Added dedicated mobile save button above tabs (sm:hidden)
- Fixed desktop button to hide on mobile (hidden sm:flex)
- Ensures save button is always visible on any screen size

Issue #166: Product validation error on create
- Changed category field from text input to select dropdown
- Now properly submits category_id matching validation rules
- Dropdown populated from business product_categories
2025-12-10 13:22:00 -07:00
kelly
38cba2cd72 Merge pull request 'fix: resolve multiple Gitea issues (#161-#167)' (#170) from fix/gitea-issues-161-167 into develop 2025-12-10 19:32:25 +00:00
kelly
735e09ab90 perf: optimize brands page and fix brand colors
- Move route generation from Blade to controller for brands index
- Remove mock random data that was slowing page render
- Update CSS with correct Cannabrands brand colors:
  - Primary: #4B6FA4 (muted blue)
  - Success: #4E8D71 (muted green)
  - Error: #E1524D (clean red)
- Add "DO NOT CHANGE" comments to prevent color changes
- Simplify brand cards and insights panel to use real data
2025-12-10 12:26:11 -07:00
kelly
05ef21cd71 fix: resolve multiple Gitea issues (#161-#167)
Issues fixed:
- #161: Quote submission - add tax_rate migration (already existed)
- #162: Contact edit 404 - change contact->id to contact->hashid in routes
- #163: Batch creation - expand batch_type constraint to include component/homogenized
- #164: Stock search - convert client-side to server-side search
- #165: Product edit save button - make always visible with different states
- #166: Product creation validation - make price_unit nullable with default
- #167: Invoice products - change stockFilter default from true to false

Additional fixes:
- Fix layouts.seller (non-existent) to layouts.app-with-sidebar in 14 views
- Fix CRM route names from seller.crm.* to seller.business.crm.*
2025-12-10 11:24:01 -07:00
kelly
65c65bf9cc fix: add missing tax_rate column to crm_quotes table 2025-12-09 17:36:10 -07:00
kelly
e33f0d0182 Merge pull request 'feat: CannaiQ Marketing Intelligence Engine (Phases 1-6)' (#160) from feature/cannaiq-marketing-intelligence into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/160
2025-12-10 00:18:55 +00:00
kelly
c8faf2f2d6 feat: add CRM account editing, cannaiq_enabled flag, and UI improvements
- Add cannaiq_enabled column to businesses table
- Add CRM account edit and contacts-edit views
- Enhance quote creation with improved contact selection
- Update search controller with additional functionality
- Add buyer businesses seeder for dev environment
- UI improvements to promotions, invoices, and sidebar
2025-12-09 16:45:31 -07:00
kelly
50bb3fce77 feat: add marketing automations / playbooks engine (Phase 6)
Adds the Marketing Automations system that allows sellers to create
automated marketing workflows triggered by CannaiQ intelligence data.

Features:
- Automation configuration with triggers (scheduled, manual)
- Condition evaluators (competitor OOS, slow mover, new store)
- Action executors (create promo, create campaign)
- Scheduled command and queued job execution
- Full CRUD UI with quick-start presets
- Run history tracking with detailed logs

Components:
- MarketingAutomation and MarketingAutomationRun models
- AutomationRunner service with extensible condition/action handlers
- RunDueMarketingAutomations command for cron scheduling
- RunMarketingAutomationJob for Horizon-backed execution
- Seller UI at /s/{business}/marketing/automations
2025-12-09 16:41:32 -07:00
kelly
c7fdc67060 feat: add dispensary marketing portal (Phase 5)
Add white-labeled marketing portal for dispensary partners:

- Business branding settings table and model for white-labeling
- Portal middleware (EnsureMarketingPortalAccess) with contact_type check
- Portal route group at /portal/{business}/*
- DashboardController with stats and CannaiQ recommendations
- PromoController for viewing recommended/existing promos
- CampaignController with create/send/schedule/cancel
- ListController for managing contact lists
- Policies for MarketingCampaign and MarketingPromo
- White-labeled portal layout with custom branding CSS
- Marketing Portal link in Filament admin for buyer businesses
- MarketingPortalUserSeeder for development testing
- PORTAL_ACCESS.md documentation
2025-12-09 15:59:40 -07:00
kelly
c7e2b0e4ac feat: add messaging layer for marketing campaigns (Phase 4)
Add contact & list management, email/SMS sending infrastructure, and
promo-to-campaign integration for the CannaiQ Marketing Intelligence Engine.

New models:
- MarketingContact: multi-type contacts (buyer, consumer, internal)
- MarketingList: static and smart lists for campaign targeting
- MarketingCampaign: direct email/SMS campaigns with list targeting
- MarketingMessageLog: send tracking per message

New services:
- EmailSender: uses business SMTP via MailSettingsResolver
- SmsSender: uses platform SMS via SmsProviderSettings (Twilio)

New features:
- Contacts CRUD with add-to-list functionality
- Lists CRUD with contact management
- Campaign creation pre-filled from promo copy
- Job-based sending with 100-message batches
- Scheduled campaign dispatch command

Updates sidebar with Contacts and Lists menu items under Growth.
2025-12-09 13:41:21 -07:00
kelly
0cf83744db chore: clarify comment in MarketingIntelligenceService 2025-12-09 12:40:07 -07:00
kelly
defeeffa07 docs: update CannaiQ API docs and client to use correct endpoints
- Changed endpoints from /dispensaries to /stores
- Changed auth from Bearer token to X-API-Key header
- Added new endpoint methods: getStoreProducts, getStoreProductMetrics,
  getStoreCompetitorSnapshot, getProductHistory, healthCheck, ping
- Updated documentation with all available endpoints and example responses
2025-12-09 12:39:47 -07:00
kelly
0fbf99c005 feat: add CannaiQ Marketing Intelligence Engine foundation
Phase 1 - Foundations:
- Add CannaiQ API client (CannaiqClient) with retry/backoff
- Add cannaiq config to services.php
- Create migrations for store_metrics, product_metrics, marketing_promos
- Create Cannaiq models (StoreMetric, ProductMetric)
- Create MarketingPromo model with promo types and statuses
- Add routes for /marketing/intelligence and /marketing/promos
- Add Intelligence and Promos to Growth menu section

Phase 2 - Intelligence Dashboard:
- Create MarketingIntelligenceService for data orchestration
- Wire IntelligenceController to service for data fetching
- Add intelligence views (index, store, product) with stub UI

Phase 3 - Promo Builder:
- Create PromoRecommendationService with AI recommendations
- Wire PromoController to service for recommendations/estimates
- Add SMS/email copy generation
- Add promo views (index, create, show, edit)

Also includes CannaiQ API documentation at docs/marketing/CANNAIQ_API.md
2025-12-09 12:35:00 -07:00
kelly
67eb679c7e fix: load contacts dynamically based on selected account in quote create
- Contact select now loads contacts for the selected customer account via AJAX
- Removed static $contacts loading from QuoteController (was loading seller contacts)
- Fixed security validation to verify contact belongs to account, not seller
- Shows 'Select account first' placeholder when no account selected
2025-12-09 12:28:06 -07:00
kelly
3b7f3acaa6 Merge pull request 'feat: CRM UI improvements and Alpine modal fixes' (#159) from fix/brand-hashid-fallback into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/159
2025-12-09 18:02:03 +00:00
kelly
3d1f3b1057 fix: use migrate only, never fresh or seed for deployments 2025-12-09 10:52:33 -07:00
kelly
7a2748e904 fix: remove postgres commands override causing root execution error 2025-12-09 10:49:59 -07:00
kelly
4f2061cd00 fix: add postgres health check wait in CI tests 2025-12-09 10:47:56 -07:00
kelly
8bb9044f2d ci: trigger rebuild 2025-12-09 10:44:55 -07:00
kelly
7da52677d5 fix: add parallel-lint as dev dependency to avoid CI network issues
Moves php-parallel-lint from global composer install during CI to a
project dev dependency. This uses the cached vendor folder instead of
requiring network access to Packagist during CI runs.
2025-12-09 10:31:04 -07:00
kelly
a049db38a9 fix: add eager loading for product images in BrandController
Fixes lazy loading violation on Product.images relation which was
causing BrandDashboardTest failures. Added `with('images')` eager
loading in both the dashboard method and calculateBrandInsights.
2025-12-09 09:50:43 -07:00
kelly
bb60a772f9 feat: CRM UI improvements and Alpine modal fixes
- Redesign Account detail page as "Account Command Center"
  - Compact metric tiles, financial snapshot strip
  - Quick actions row and tab navigation
  - Two-column layout with quotes, orders, invoices, tasks
- Fix Alpine.js modal initialization timing issues
  - send-menu-modal: register component and init if Alpine started
  - create-opportunity-modal: same fix
- Fix user dropdown not opening (remove style="display:none", use x-cloak)
- Add search routes and SearchController for seller context
- Various CRM view updates for consistency
2025-12-09 09:12:27 -07:00
kelly
95d92f27d3 refactor: update Inbox and Contacts screens for visual consistency
- Inbox: Add proper page header, card-wrapped 2-column layout
- Inbox: Improved sidebar with filters, search, and conversation list
- Inbox: Better empty state for conversation pane
- Contacts: Standardized header with description
- Contacts: Toolbar with search and type filter
- Contacts: Table-based layout wrapped in card
- Contacts: Improved empty state messaging
- Both: Aligned with Sales Dashboard, Accounts, Orders, Quotes styling
2025-12-09 01:00:08 -07:00
kelly
f08910bbf4 fix: add missing stage column to crm_deals table
The DealController queries by stage name but the table only had stage_id.
Added stage column with composite index for efficient kanban board queries.
2025-12-09 00:56:48 -07:00
kelly
e043137269 fix: add deleted_at column to crm_sla_policies and fix route names
- Add migration for SoftDeletes support in CrmSlaPolicy model
- Fix incorrect route names in CRM settings index view
  - templates → templates.index
  - roles → roles.index
2025-12-08 22:37:12 -07:00
kelly
de988d9abd fix: add missing period columns to crm_rep_metrics table 2025-12-08 22:22:08 -07:00
kelly
72df0cfe88 feat: add Quotes to Commerce menu in Sales Suite 2025-12-08 22:14:41 -07:00
kelly
65a752f4d8 Merge pull request 'feat: CRM leads system, customer creation, and various fixes' (#158) from fix/brand-hashid-fallback into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/158
2025-12-09 01:04:50 +00:00
kelly
7d0230be5f feat: add CRM leads system and customer creation
- Add CrmLead model for tracking prospects not yet in system
- Add LeadController with full CRUD operations
- Add leads views (index, create, show, edit)
- Add create/store methods to AccountController for direct customer creation
- Add customer create form view
- Add routes for leads and customer creation
- Update accounts index with Add Customer and Leads buttons
2025-12-08 17:51:59 -07:00
kelly
75305a01b0 fix: use ILIKE for case-insensitive search in orders and invoices 2025-12-08 17:48:13 -07:00
kelly
f2ce0dfee3 fix: batch creation and brand settings checkbox change detection 2025-12-08 17:38:36 -07:00
kelly
1222610080 fix: CRM accounts search, orders, and activity pages 2025-12-08 17:36:23 -07:00
kelly
c1d0cdf477 fix: use server-side search on products index 2025-12-08 17:29:57 -07:00
kelly
a55ea906ac fix: add hashid to brand eager load and defensive fallback
- Add hashid to brand eager loading in ProductController
- Add defensive fallback in Brand::getLogoUrl() and getBannerUrl()
- Falls back to direct Storage URL if hashid is missing
- Prevents route generation errors from incomplete eager loading
2025-12-08 17:29:57 -07:00
kelly
70e274415d Merge pull request 'fix: add hashid to brand eager load and defensive fallback' (#157) from fix/brand-hashid-fallback into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/157
2025-12-09 00:15:56 +00:00
kelly
fca89475cc fix: add hashid to brand eager load and defensive fallback
- Add hashid to brand eager loading in ProductController
- Add defensive fallback in Brand::getLogoUrl() and getBannerUrl()
- Falls back to direct Storage URL if hashid is missing
- Prevents route generation errors from incomplete eager loading
2025-12-08 17:10:29 -07:00
kelly
a88eeb7981 Merge pull request 'fix: make product search case-insensitive and optimize queries' (#156) from fix/product-search-case-insensitive into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/156
2025-12-08 23:27:05 +00:00
kelly
eed4df0c4a fix: make product search case-insensitive and optimize queries
- Use ILIKE instead of LIKE for PostgreSQL case-insensitive search
- Reduce eager loading in index() to only needed columns
- Remove images relation (use image_path column instead)
- Add pagination to listings() method
- Filter hashid at DB level instead of PHP collection
2025-12-08 16:21:58 -07:00
kelly
915b0407cf Merge pull request 'fix: pass $business to all CRM views and add sidebar menu ordering' (#155) from perf/controller-query-optimization into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/155
2025-12-08 21:49:52 +00:00
391 changed files with 44346 additions and 8383 deletions

View File

@@ -7,17 +7,36 @@
# - tags (2025.X) → cannabrands.app (versioned production releases)
#
# Pipeline Strategy:
# - PRs: Run tests (lint, style, phpunit)
# - PRs: Run tests (lint, style, phpunit) IN PARALLEL
# - Push to develop/master: Skip tests (already passed on PR), build + deploy
# - Tags: Build versioned release
#
# Optimization Notes:
# - php-lint, code-style, and tests run in parallel after composer install
# - Uses parallel-lint for faster PHP syntax checking
# - PostgreSQL tuned for CI (fsync disabled)
# - Cache rebuild only on merge builds
when:
- branch: [develop, master]
event: push
- event: [pull_request, tag]
# Install dependencies first (needed for php-lint to resolve traits/classes)
# Use explicit git clone plugin to fix auth issues
# The default clone was failing with "could not read Username"
clone:
git:
image: woodpeckerci/plugin-git
settings:
depth: 50
lfs: false
partial: false
steps:
# ============================================
# DEPENDENCY INSTALLATION (Sequential)
# ============================================
# Restore Composer cache
restore-composer-cache:
image: meltwater/drone-cache:dev
@@ -34,6 +53,8 @@ steps:
# Install dependencies (uses pre-built Laravel image with all extensions)
composer-install:
image: kirschbaumdevelopment/laravel-test-runner:8.3
depends_on:
- restore-composer-cache
commands:
- echo "Creating minimal .env for package discovery..."
- |
@@ -63,9 +84,11 @@ steps:
fi
- echo "✅ Composer dependencies ready!"
# Rebuild Composer cache
# Rebuild Composer cache (only on merge builds, not PRs)
rebuild-composer-cache:
image: meltwater/drone-cache:dev
depends_on:
- composer-install
settings:
backend: "filesystem"
rebuild: true
@@ -75,22 +98,31 @@ steps:
- "vendor"
volumes:
- /tmp/woodpecker-cache:/tmp/cache
when:
branch: [develop, master]
event: push
# PHP Syntax Check (PRs only - skipped on merge since tests already passed)
# ============================================
# PR CHECKS (Run in Parallel for Speed)
# ============================================
# PHP Syntax Check - Uses parallel-lint for 5-10x speed improvement
php-lint:
image: kirschbaumdevelopment/laravel-test-runner:8.3
depends_on:
- composer-install
commands:
- echo "Checking PHP syntax..."
- find app -name "*.php" -exec php -l {} \; 2>&1 | grep -v "No syntax errors" || true
- find routes -name "*.php" -exec php -l {} \; 2>&1 | grep -v "No syntax errors" || true
- find database -name "*.php" -exec php -l {} \; 2>&1 | grep -v "No syntax errors" || true
- echo "Checking PHP syntax (parallel)..."
- ./vendor/bin/parallel-lint app routes database config --colors --blame
- echo "✅ PHP syntax check complete!"
when:
event: pull_request
# Run Laravel Pint (PRs only - skipped on merge since tests already passed)
# Run Laravel Pint (code style)
code-style:
image: kirschbaumdevelopment/laravel-test-runner:8.3
depends_on:
- composer-install
commands:
- echo "Checking code style with Laravel Pint..."
- ./vendor/bin/pint --test
@@ -98,11 +130,13 @@ steps:
when:
event: pull_request
# Run PHPUnit Tests (PRs only - skipped on merge since tests already passed)
# Run PHPUnit Tests
# Note: Uses array cache/session for speed and isolation (Laravel convention)
# Redis + Reverb services used for real-time broadcasting tests
tests:
image: kirschbaumdevelopment/laravel-test-runner:8.3
depends_on:
- composer-install
when:
event: pull_request
environment:
@@ -128,20 +162,35 @@ steps:
- echo "Setting up Laravel environment..."
- cp .env.example .env
- php artisan key:generate
- echo "Waiting for PostgreSQL to be ready..."
- |
for i in 1 2 3 4 5 6 7 8 9 10; do
if pg_isready -h postgres -p 5432 -U testing 2>/dev/null; then
echo "✅ PostgreSQL is ready!"
break
fi
echo "Waiting for postgres... attempt $i/10"
sleep 3
done
- echo "Starting Reverb server in background..."
- php artisan reverb:start --host=0.0.0.0 --port=8080 > /dev/null 2>&1 &
- sleep 2
- echo "Running tests..."
- echo "Running tests in parallel..."
- php artisan test --parallel
- echo "Tests complete!"
- echo "Tests complete!"
# Validate seeders that run in dev/staging environments
# This prevents deployment failures caused by seeder errors (e.g., fake() crashes)
# Uses APP_ENV=development to match K8s init container behavior
validate-seeders:
# ============================================
# MERGE BUILD STEPS (Sequential, after PR passes)
# ============================================
# Validate migrations before deployment
# Only runs pending migrations - never fresh or seed
validate-migrations:
image: kirschbaumdevelopment/laravel-test-runner:8.3
depends_on:
- composer-install
environment:
APP_ENV: development
APP_ENV: production
DB_CONNECTION: pgsql
DB_HOST: postgres
DB_PORT: 5432
@@ -152,20 +201,21 @@ steps:
SESSION_DRIVER: array
QUEUE_CONNECTION: sync
commands:
- echo "Validating seeders (matches K8s init container)..."
- echo "Validating migrations..."
- cp .env.example .env
- php artisan key:generate
- echo "Running migrate:fresh --seed with APP_ENV=development..."
- php artisan migrate:fresh --seed --force
- echo "✅ Seeder validation complete!"
- echo "Running pending migrations only..."
- php artisan migrate --force
- echo "✅ Migration validation complete!"
when:
branch: [develop, master]
event: push
status: success
# Build and push Docker image for DEV environment (develop branch)
build-image-dev:
image: woodpeckerci/plugin-docker-buildx
depends_on:
- validate-migrations
settings:
registry: code.cannabrands.app
repo: code.cannabrands.app/cannabrands/hub
@@ -194,11 +244,12 @@ steps:
when:
branch: develop
event: push
status: success
# Auto-deploy to dev.cannabrands.app (develop branch only)
deploy-dev:
image: bitnami/kubectl:latest
depends_on:
- build-image-dev
environment:
KUBECONFIG_CONTENT:
from_secret: kubeconfig_dev
@@ -231,11 +282,12 @@ steps:
when:
branch: develop
event: push
status: success
# Build and push Docker image for PRODUCTION (master branch)
build-image-production:
image: woodpeckerci/plugin-docker-buildx
depends_on:
- validate-migrations
settings:
registry: code.cannabrands.app
repo: code.cannabrands.app/cannabrands/hub
@@ -260,11 +312,12 @@ steps:
when:
branch: master
event: push
status: success
# Deploy to production (master branch)
deploy-production:
image: bitnami/kubectl:latest
depends_on:
- build-image-production
environment:
KUBECONFIG_CONTENT:
from_secret: kubeconfig_prod
@@ -288,11 +341,12 @@ steps:
when:
branch: master
event: push
status: success
# Build and push Docker image for tagged releases (optional versioned releases)
build-image-release:
image: woodpeckerci/plugin-docker-buildx
depends_on:
- composer-install
settings:
registry: code.cannabrands.app
repo: code.cannabrands.app/cannabrands/hub
@@ -313,7 +367,6 @@ steps:
provenance: false
when:
event: tag
status: success
# Success notification
success:
@@ -384,7 +437,7 @@ steps:
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
fi
# Services for tests
# Services for tests (optimized for CI speed)
services:
postgres:
image: postgres:15
@@ -392,6 +445,9 @@ services:
POSTGRES_USER: testing
POSTGRES_PASSWORD: testing
POSTGRES_DB: testing
# CI-optimized settings via environment (faster writes, safe for ephemeral test DB)
POSTGRES_INITDB_ARGS: "--data-checksums"
POSTGRES_HOST_AUTH_METHOD: trust
redis:
image: redis:7-alpine

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Console\Commands;
use App\Jobs\SendMarketingCampaignJob;
use App\Models\Marketing\MarketingCampaign;
use Illuminate\Console\Command;
/**
* DispatchScheduledCampaigns - Dispatch scheduled marketing campaigns.
*
* Run via scheduler: Schedule::command('marketing:dispatch-scheduled-campaigns')->everyMinute();
*/
class DispatchScheduledCampaigns extends Command
{
protected $signature = 'marketing:dispatch-scheduled-campaigns';
protected $description = 'Dispatch scheduled marketing campaigns that are ready to send';
public function handle(): int
{
$campaigns = MarketingCampaign::readyToSend()->get();
if ($campaigns->isEmpty()) {
$this->info('No scheduled campaigns ready to send.');
return self::SUCCESS;
}
$this->info("Found {$campaigns->count()} campaign(s) ready to send.");
foreach ($campaigns as $campaign) {
$this->info("Dispatching campaign: {$campaign->name} (ID: {$campaign->id})");
SendMarketingCampaignJob::dispatch($campaign->id);
}
$this->info('Done.');
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,108 @@
<?php
namespace App\Console\Commands;
use App\Jobs\RunMarketingAutomationJob;
use App\Models\Marketing\MarketingAutomation;
use Illuminate\Console\Command;
class RunDueMarketingAutomations extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'marketing:run-due-automations
{--business= : Only process automations for a specific business ID}
{--dry-run : Show which automations would run without executing them}
{--sync : Run synchronously instead of dispatching to queue}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Check and run all due marketing automations';
/**
* Execute the console command.
*/
public function handle(): int
{
$businessId = $this->option('business');
$dryRun = $this->option('dry-run');
$sync = $this->option('sync');
$this->info('Checking for due marketing automations...');
// Query active automations
$query = MarketingAutomation::where('is_active', true)
->whereIn('trigger_type', [
MarketingAutomation::TRIGGER_SCHEDULED_CANNAIQ_CHECK,
MarketingAutomation::TRIGGER_SCHEDULED_STORE_CHECK,
]);
if ($businessId) {
$query->where('business_id', $businessId);
}
$automations = $query->get();
if ($automations->isEmpty()) {
$this->info('No active automations found.');
return Command::SUCCESS;
}
$this->info("Found {$automations->count()} active automation(s).");
$dueCount = 0;
foreach ($automations as $automation) {
if (! $automation->isDue()) {
$this->line(" - <comment>{$automation->name}</comment>: Not due yet");
continue;
}
$dueCount++;
if ($dryRun) {
$this->line(" - <info>{$automation->name}</info>: Would run (dry-run mode)");
$this->line(" Trigger: {$automation->trigger_type_label}");
$this->line(" Frequency: {$automation->frequency_label}");
$this->line(' Last run: '.($automation->last_run_at?->diffForHumans() ?? 'Never'));
continue;
}
$this->line(" - <info>{$automation->name}</info>: Dispatching...");
if ($sync) {
// Run synchronously
try {
$job = new RunMarketingAutomationJob($automation->id);
$job->handle(app(\App\Services\Marketing\AutomationRunner::class));
$this->line(' <info>Completed</info>');
} catch (\Exception $e) {
$this->error(" Failed: {$e->getMessage()}");
}
} else {
// Dispatch to queue
RunMarketingAutomationJob::dispatch($automation->id);
$this->line(' <info>Dispatched to queue</info>');
}
}
if ($dryRun) {
$this->newLine();
$this->info("Dry run complete. {$dueCount} automation(s) would have been executed.");
} else {
$this->newLine();
$this->info("Done. {$dueCount} automation(s) ".($sync ? 'executed' : 'dispatched').'.');
}
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,203 @@
<?php
namespace App\Filament\Pages;
use App\Services\Cannaiq\CannaiqClient;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\HtmlString;
class CannaiqSettings extends Page implements HasForms
{
use InteractsWithForms;
protected static \BackedEnum|string|null $navigationIcon = 'heroicon-o-chart-bar-square';
protected string $view = 'filament.pages.cannaiq-settings';
protected static \UnitEnum|string|null $navigationGroup = 'Integrations';
protected static ?string $navigationLabel = 'CannaiQ';
protected static ?int $navigationSort = 1;
protected static ?string $title = 'CannaiQ Settings';
protected static ?string $slug = 'cannaiq-settings';
public ?array $data = [];
public static function canAccess(): bool
{
return auth('admin')->check();
}
public function mount(): void
{
$this->form->fill([
'base_url' => config('services.cannaiq.base_url'),
'api_key' => '', // Never show the actual key
'cache_ttl' => config('services.cannaiq.cache_ttl', 7200),
]);
}
public function form(Schema $schema): Schema
{
$apiKeyConfigured = ! empty(config('services.cannaiq.api_key'));
$baseUrl = config('services.cannaiq.base_url');
return $schema
->schema([
Section::make('CannaiQ Integration')
->description('CannaiQ is the Marketing Intelligence Engine that powers competitive analysis, pricing intelligence, and promotional recommendations.')
->schema([
Placeholder::make('status')
->label('Connection Status')
->content(function () use ($apiKeyConfigured, $baseUrl) {
$statusHtml = '<div class="space-y-2">';
// API Key status
if ($apiKeyConfigured) {
$statusHtml .= '<div class="flex items-center gap-2 text-success-600 dark:text-success-400">'.
'<span class="text-lg">&#10003;</span>'.
'<span>API Key configured</span>'.
'</div>';
} else {
$statusHtml .= '<div class="flex items-center gap-2 text-warning-600 dark:text-warning-400">'.
'<span class="text-lg">&#9888;</span>'.
'<span>API Key not configured (using trusted origin auth)</span>'.
'</div>';
}
// Base URL
$statusHtml .= '<div class="text-sm text-gray-500 dark:text-gray-400">'.
'Base URL: <code class="bg-gray-100 dark:bg-gray-800 px-1 rounded">'.$baseUrl.'</code>'.
'</div>';
$statusHtml .= '</div>';
return new HtmlString($statusHtml);
}),
Placeholder::make('features')
->label('Features Enabled')
->content(new HtmlString(
'<div class="rounded-lg border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-900 p-4">'.
'<ul class="list-disc list-inside text-sm text-gray-600 dark:text-gray-400 space-y-1">'.
'<li><strong>Brand Analysis</strong> - Market positioning, SKU velocity, shelf opportunities</li>'.
'<li><strong>Marketing Intelligence</strong> - Competitive insights and recommendations</li>'.
'<li><strong>Promo Recommendations</strong> - AI-powered promotional strategies</li>'.
'<li><strong>Store Playbook</strong> - Actionable insights for retail partners</li>'.
'</ul>'.
'</div>'
)),
]),
Section::make('Configuration')
->description('CannaiQ is configured via environment variables. Update your .env file to change these settings.')
->schema([
TextInput::make('base_url')
->label('Base URL')
->disabled()
->helperText('Set via CANNAIQ_BASE_URL environment variable'),
TextInput::make('cache_ttl')
->label('Cache TTL (seconds)')
->disabled()
->helperText('Set via CANNAIQ_CACHE_TTL environment variable. Default: 7200 (2 hours)'),
Placeholder::make('env_example')
->label('Environment Variables')
->content(new HtmlString(
'<div class="rounded-lg bg-gray-900 text-gray-100 p-4 font-mono text-sm overflow-x-auto">'.
'<div class="text-gray-400"># CannaiQ Configuration</div>'.
'<div>CANNAIQ_BASE_URL=https://cannaiq.co/api/v1</div>'.
'<div>CANNAIQ_API_KEY=your-api-key-here</div>'.
'<div>CANNAIQ_CACHE_TTL=7200</div>'.
'</div>'
)),
])
->collapsed(),
Section::make('Business Access')
->description('CannaiQ features must be enabled per-business in the Business settings.')
->schema([
Placeholder::make('business_info')
->label('')
->content(new HtmlString(
'<div class="rounded-lg border border-info-200 bg-info-50 dark:border-info-800 dark:bg-info-950 p-4">'.
'<div class="flex items-start gap-3">'.
'<span class="text-info-600 dark:text-info-400 text-lg">&#9432;</span>'.
'<div class="text-sm">'.
'<p class="font-medium text-info-800 dark:text-info-200">How to enable CannaiQ for a business:</p>'.
'<ol class="list-decimal list-inside mt-2 text-info-700 dark:text-info-300 space-y-1">'.
'<li>Go to <strong>Users &rarr; Businesses</strong></li>'.
'<li>Edit the business</li>'.
'<li>Go to the <strong>Integrations</strong> tab</li>'.
'<li>Toggle <strong>Enable CannaiQ</strong></li>'.
'</ol>'.
'</div>'.
'</div>'.
'</div>'
)),
]),
])
->statePath('data');
}
public function testConnection(): void
{
try {
$client = app(CannaiqClient::class);
// Try to fetch something from the API to verify connection
// We'll use a simple health check or fetch minimal data
$response = $client->getBrandAnalysis('test-brand', 'test-business');
// If we get here without exception, connection works
// (even if the response is empty/error from CannaiQ side)
Notification::make()
->title('Connection Test')
->body('Successfully connected to CannaiQ API')
->success()
->send();
} catch (\Exception $e) {
Notification::make()
->title('Connection Failed')
->body($e->getMessage())
->danger()
->send();
}
}
public function clearCache(): void
{
// Clear all CannaiQ-related cache keys
$patterns = [
'cannaiq:*',
'brand_analysis:*',
];
$cleared = 0;
foreach ($patterns as $pattern) {
// Note: This is a simplified clear - in production you might want
// to use Redis SCAN for pattern matching
Cache::forget($pattern);
$cleared++;
}
Notification::make()
->title('Cache Cleared')
->body('CannaiQ cache has been cleared')
->success()
->send();
}
}

View File

@@ -852,6 +852,40 @@ class BusinessResource extends Resource
]),
]),
// ===== INTEGRATIONS TAB =====
// Third-party service integrations
Tab::make('Integrations')
->icon('heroicon-o-link')
->schema([
// ===== CANNAIQ SECTION =====
Section::make('CannaiQ')
->description('CannaiQ is the Marketing Intelligence Engine that powers competitive analysis, pricing intelligence, and promotional recommendations.')
->schema([
Toggle::make('cannaiq_enabled')
->label('Enable CannaiQ')
->helperText('When enabled, this business gets access to Brand Analysis, Intelligence, and Promos features.')
->default(false),
Forms\Components\Placeholder::make('cannaiq_info')
->label('')
->content(new \Illuminate\Support\HtmlString(
'<div class="rounded-lg border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-900 p-4 text-sm">'.
'<div class="font-medium text-gray-700 dark:text-gray-300 mb-2">CannaiQ Features</div>'.
'<ul class="list-disc list-inside text-gray-600 dark:text-gray-400 space-y-1">'.
'<li>Brand Analysis - Market positioning, SKU velocity, shelf opportunities</li>'.
'<li>Marketing Intelligence - Competitive insights and recommendations</li>'.
'<li>Promo Recommendations - AI-powered promotional strategies</li>'.
'</ul>'.
'<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">'.
'<a href="https://cannaiq.co" target="_blank" class="text-primary-600 hover:text-primary-500 dark:text-primary-400 font-medium inline-flex items-center gap-1">'.
'Visit CannaiQ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg>'.
'</a>'.
'</div>'.
'</div>'
)),
]),
]),
// ===== LEGACY MODULES TAB =====
// These flags are kept for backward compatibility.
// The recommended way to configure access is via Suites above.

View File

@@ -28,6 +28,14 @@ class EditBusiness extends EditRecord
protected function getHeaderActions(): array
{
return [
Actions\Action::make('view_marketing_portal')
->label('Marketing Portal')
->icon('heroicon-o-megaphone')
->color('info')
->url(fn () => route('portal.dashboard', $this->record->slug))
->openUrlInNewTab()
->visible(fn () => $this->record->status === 'approved' && $this->record->business_type === 'buyer'),
Actions\Action::make('approve_application')
->label('Approve Application')
->icon('heroicon-o-check-circle')

View File

@@ -2,7 +2,13 @@
namespace App\Http\Controllers;
use App\Models\Analytics\BuyerEngagementScore;
use App\Models\Business;
use App\Models\Crm\CrmDeal;
use App\Models\Crm\CrmMeetingBooking;
use App\Models\Crm\CrmTask;
use App\Models\Crm\CrmThread;
use App\Services\Crm\CrmSlaService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
@@ -39,7 +45,7 @@ class DashboardController extends Controller
* NOTE: All metrics are pre-calculated by CalculateDashboardMetrics job
* and stored in Redis. This method only reads from Redis for instant response.
*/
public function overview(Business $business)
public function overview(Request $request, Business $business)
{
// Read pre-calculated metrics from Redis
$redisKey = "dashboard:{$business->id}:overview";
@@ -104,6 +110,12 @@ class DashboardController extends Controller
// Orchestrator Widget Data (if enabled)
$orchestratorWidget = (new \App\Http\Controllers\Seller\OrchestratorController)->getWidgetData($business);
// Hub Tiles Data (CRM, Tasks, Calendar, etc.)
$hubTiles = $this->getHubTilesData($business, $request->user());
// Sales Inbox - unified view of items needing attention
$salesInbox = $this->getSalesInboxData($business, $request->user());
return view('seller.dashboard.overview', compact(
'business',
'revenueLast30',
@@ -122,7 +134,9 @@ class DashboardController extends Controller
'topBrands',
'needsAttention',
'recentActivity',
'orchestratorWidget'
'orchestratorWidget',
'hubTiles',
'salesInbox'
));
}
@@ -1195,4 +1209,289 @@ class DashboardController extends Controller
// Sort all activities by timestamp (most recent first) and apply limit
return $activities->sortByDesc('timestamp')->take($limit)->values();
}
/**
* Get Sales Inbox data - unified view of items needing sales rep attention
* Includes overdue invoices, deals needing follow-up, tasks, and messages
*/
protected function getSalesInboxData(Business $business, $user): array
{
$overdue = [];
$upcoming = [];
$messages = [];
// Get brand IDs for this business
$brandIds = \App\Http\Controllers\Seller\BrandSwitcherController::getFilteredBrandIds();
// Overdue invoices
$overdueInvoices = \App\Models\Invoice::whereHas('order.items.product.brand', function ($query) use ($brandIds) {
$query->whereIn('id', $brandIds);
})
->where('payment_status', 'pending')
->where('due_date', '<', now())
->with(['business:id,name', 'order:id,order_number'])
->orderBy('due_date', 'asc')
->limit(10)
->get();
foreach ($overdueInvoices as $invoice) {
$daysOverdue = now()->diffInDays($invoice->due_date, false);
$overdue[] = [
'type' => 'invoice',
'label' => "Invoice {$invoice->invoice_number} for {$invoice->business->name}",
'age' => $daysOverdue,
'link' => route('seller.business.invoices.show', [$business->slug, $invoice->invoice_number]),
];
}
// Overdue tasks
$overdueTasks = CrmTask::where('seller_business_id', $business->id)
->whereNull('completed_at')
->where('due_at', '<', now())
->with(['contact:id,first_name,last_name,company_name'])
->orderBy('due_at', 'asc')
->limit(10)
->get();
foreach ($overdueTasks as $task) {
$daysOverdue = now()->diffInDays($task->due_at, false);
$contactName = $task->contact
? trim($task->contact->first_name.' '.$task->contact->last_name) ?: $task->contact->company_name
: 'Unknown';
$overdue[] = [
'type' => 'task',
'label' => $task->title.($contactName ? " ({$contactName})" : ''),
'age' => $daysOverdue,
'link' => route('seller.business.crm.tasks.index', $business->slug),
];
}
// Deals needing follow-up (no activity in 7+ days)
$staleDeals = CrmDeal::forBusiness($business->id)
->where('status', 'open')
->where(function ($q) {
$q->whereNull('last_activity_at')
->orWhere('last_activity_at', '<', now()->subDays(7));
})
->with(['account:id,name'])
->orderBy('last_activity_at', 'asc')
->limit(5)
->get();
foreach ($staleDeals as $deal) {
$daysSinceActivity = $deal->last_activity_at
? now()->diffInDays($deal->last_activity_at, false)
: -30;
$overdue[] = [
'type' => 'deal',
'label' => $deal->name.($deal->account ? " ({$deal->account->name})" : ''),
'age' => $daysSinceActivity,
'link' => route('seller.business.crm.deals.show', [$business->slug, $deal->hashid]),
];
}
// Upcoming tasks (due in next 7 days)
$upcomingTasks = CrmTask::where('seller_business_id', $business->id)
->whereNull('completed_at')
->where('due_at', '>=', now())
->where('due_at', '<=', now()->addDays(7))
->with(['contact:id,first_name,last_name,company_name'])
->orderBy('due_at', 'asc')
->limit(10)
->get();
foreach ($upcomingTasks as $task) {
$daysUntilDue = now()->diffInDays($task->due_at, false);
$contactName = $task->contact
? trim($task->contact->first_name.' '.$task->contact->last_name) ?: $task->contact->company_name
: 'Unknown';
$upcoming[] = [
'type' => 'task',
'label' => $task->title.($contactName ? " ({$contactName})" : ''),
'days_until' => abs($daysUntilDue),
'link' => route('seller.business.crm.tasks.index', $business->slug),
];
}
// Upcoming meetings (next 7 days)
$upcomingMeetings = CrmMeetingBooking::where('business_id', $business->id)
->where('status', 'scheduled')
->where('start_at', '>=', now())
->where('start_at', '<=', now()->addDays(7))
->with(['contact:id,first_name,last_name,company_name'])
->orderBy('start_at', 'asc')
->limit(5)
->get();
foreach ($upcomingMeetings as $meeting) {
$daysUntil = now()->diffInDays($meeting->start_at, false);
$contactName = $meeting->contact
? trim($meeting->contact->first_name.' '.$meeting->contact->last_name) ?: $meeting->contact->company_name
: 'Unknown';
$upcoming[] = [
'type' => 'meeting',
'label' => ($meeting->title ?? 'Meeting')." with {$contactName}",
'days_until' => abs($daysUntil),
'link' => route('seller.business.crm.calendar.index', $business->slug),
];
}
// CRM Messages (unread threads)
$unreadThreads = CrmThread::forBusiness($business->id)
->where('status', 'open')
->where('is_read', false)
->with(['contact:id,name,email'])
->orderBy('last_message_at', 'desc')
->limit(5)
->get();
foreach ($unreadThreads as $thread) {
$messages[] = [
'type' => 'message',
'label' => $thread->contact->name ?? $thread->contact->email ?? 'Unknown contact',
'preview' => $thread->last_message_preview ?? 'New message',
'time' => $thread->last_message_at?->diffForHumans() ?? 'Recently',
'link' => route('seller.business.crm.threads.show', [$business->slug, $thread->hashid]),
];
}
// Sort overdue by age (most overdue first)
usort($overdue, fn ($a, $b) => $a['age'] <=> $b['age']);
// Sort upcoming by days until due
usort($upcoming, fn ($a, $b) => $a['days_until'] <=> $b['days_until']);
return [
'overdue' => $overdue,
'upcoming' => $upcoming,
'messages' => $messages,
];
}
/**
* Get summary data for dashboard hub tiles
* Each tile shows key metrics with a link to the detail page
*/
protected function getHubTilesData(Business $business, $user): array
{
$cacheKey = "dashboard_hub_tiles:{$business->id}:{$user->id}";
return Cache::remember($cacheKey, 60, function () use ($business) {
// Inbox (CRM Threads)
$inboxStats = CrmThread::forBusiness($business->id)
->selectRaw("
SUM(CASE WHEN status = 'open' THEN 1 ELSE 0 END) as open_count,
SUM(CASE WHEN status = 'open' AND is_read = false THEN 1 ELSE 0 END) as unread_count,
SUM(CASE WHEN status = 'open' AND priority = 'urgent' THEN 1 ELSE 0 END) as urgent_count
")
->first();
// Deals
$dealStats = CrmDeal::forBusiness($business->id)
->selectRaw("
SUM(CASE WHEN status = 'open' THEN 1 ELSE 0 END) as open_count,
SUM(CASE WHEN status = 'open' THEN value ELSE 0 END) as pipeline_value,
SUM(CASE WHEN status = 'won' AND EXTRACT(MONTH FROM actual_close_date) = ? AND EXTRACT(YEAR FROM actual_close_date) = ? THEN value ELSE 0 END) as won_this_month
", [now()->month, now()->year])
->first();
// Tasks
$taskStats = CrmTask::where('seller_business_id', $business->id)
->selectRaw('
SUM(CASE WHEN completed_at IS NULL THEN 1 ELSE 0 END) as pending_count,
SUM(CASE WHEN completed_at IS NULL AND DATE(due_at) = CURRENT_DATE THEN 1 ELSE 0 END) as due_today,
SUM(CASE WHEN completed_at IS NULL AND due_at < NOW() THEN 1 ELSE 0 END) as overdue_count
')
->first();
// Calendar/Meetings (upcoming in next 7 days)
$upcomingMeetings = CrmMeetingBooking::where('business_id', $business->id)
->where('status', 'scheduled')
->where('start_at', '>=', now())
->where('start_at', '<=', now()->addDays(7))
->count();
$todayMeetings = CrmMeetingBooking::where('business_id', $business->id)
->where('status', 'scheduled')
->whereDate('start_at', today())
->count();
// Buyer Intelligence
$buyerStats = [
'total' => 0,
'at_risk' => 0,
'high_value' => 0,
];
if (class_exists(BuyerEngagementScore::class)) {
$buyerStats = [
'total' => BuyerEngagementScore::forBusiness($business->id)->count(),
'at_risk' => BuyerEngagementScore::forBusiness($business->id)->where('engagement_level', 'cold')->count(),
'high_value' => BuyerEngagementScore::forBusiness($business->id)->highValue()->count(),
];
}
// Team Performance (SLA)
$slaMetrics = ['compliance_rate' => 100, 'avg_response_time' => 0];
try {
$slaService = app(CrmSlaService::class);
$slaMetrics = $slaService->getMetrics($business->id, 30);
} catch (\Exception $e) {
// SLA service not available
}
// Orchestrator tasks count
$orchestratorTasks = 0;
try {
$orchestratorData = (new \App\Http\Controllers\Seller\OrchestratorController)->getWidgetData($business);
$orchestratorTasks = $orchestratorData['total_count'] ?? 0;
} catch (\Exception $e) {
// Orchestrator not available
}
return [
'inbox' => [
'open' => $inboxStats->open_count ?? 0,
'unread' => $inboxStats->unread_count ?? 0,
'urgent' => $inboxStats->urgent_count ?? 0,
'route' => 'seller.business.crm.threads.index',
],
'deals' => [
'open' => $dealStats->open_count ?? 0,
'pipeline_value' => $dealStats->pipeline_value ?? 0,
'won_this_month' => $dealStats->won_this_month ?? 0,
'route' => 'seller.business.crm.deals.index',
],
'tasks' => [
'pending' => $taskStats->pending_count ?? 0,
'due_today' => $taskStats->due_today ?? 0,
'overdue' => $taskStats->overdue_count ?? 0,
'route' => 'seller.business.crm.tasks.index',
],
'calendar' => [
'upcoming' => $upcomingMeetings,
'today' => $todayMeetings,
'route' => 'seller.business.crm.calendar.index',
],
'buyers' => [
'total' => $buyerStats['total'],
'at_risk' => $buyerStats['at_risk'],
'high_value' => $buyerStats['high_value'],
'route' => 'seller.business.buyer-intelligence.index',
],
'team' => [
'sla_compliance' => $slaMetrics['compliance_rate'] ?? 100,
'avg_response_time' => $slaMetrics['avg_response_time'] ?? 0,
'route' => 'seller.business.crm.dashboard.team',
],
'orchestrator' => [
'tasks' => $orchestratorTasks,
'route' => 'seller.business.orchestrator.index',
],
'analytics' => [
'route' => 'seller.business.dashboard.analytics',
],
];
});
}
}

View File

@@ -73,15 +73,27 @@ class OrderController extends Controller
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('order_number', 'like', "%{$search}%")
$q->where('order_number', 'ILIKE', "%{$search}%")
->orWhereHas('business', function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%");
$q->where('name', 'ILIKE', "%{$search}%");
});
});
}
$orders = $query->paginate(20)->withQueryString();
// Return JSON for AJAX/API requests (live search)
if ($request->wantsJson()) {
return response()->json([
'data' => $orders->map(fn ($o) => [
'order_number' => $o->order_number,
'name' => $o->order_number.' - '.$o->business->name,
'customer' => $o->business->name,
'status' => $o->status,
])->values()->toArray(),
]);
}
return view('seller.orders.index', compact('orders', 'business'));
}

View File

@@ -0,0 +1,249 @@
<?php
namespace App\Http\Controllers\Portal;
use App\Http\Controllers\Controller;
use App\Jobs\SendMarketingCampaignJob;
use App\Models\Branding\BusinessBrandingSetting;
use App\Models\Business;
use App\Models\Marketing\MarketingCampaign;
use App\Models\Marketing\MarketingList;
use App\Models\Marketing\MarketingPromo;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class CampaignController extends Controller
{
public function index(Request $request, Business $business)
{
$branding = BusinessBrandingSetting::forBusiness($business);
$campaigns = MarketingCampaign::where('business_id', $business->id)
->with('list')
->when($request->status, fn ($q, $status) => $q->where('status', $status))
->when($request->channel, fn ($q, $channel) => $q->where('channel', $channel))
->latest()
->paginate(15);
$statuses = [
'draft' => 'Draft',
'scheduled' => 'Scheduled',
'sending' => 'Sending',
'sent' => 'Sent',
'completed' => 'Completed',
'cancelled' => 'Cancelled',
'failed' => 'Failed',
];
$channels = MarketingCampaign::CHANNELS;
return view('portal.campaigns.index', compact(
'business',
'branding',
'campaigns',
'statuses',
'channels'
));
}
public function create(Request $request, Business $business)
{
$branding = BusinessBrandingSetting::forBusiness($business);
// Get lists for this business
$lists = MarketingList::where('business_id', $business->id)
->withCount('contacts')
->orderBy('name')
->get();
// Pre-populate from promo if provided
$promo = null;
if ($request->query('promo_id')) {
$promo = MarketingPromo::where('business_id', $business->id)
->find($request->query('promo_id'));
}
// Pre-select channel if provided
$preselectedChannel = $request->query('channel', 'email');
$channels = MarketingCampaign::CHANNELS;
return view('portal.campaigns.create', compact(
'business',
'branding',
'lists',
'promo',
'preselectedChannel',
'channels'
));
}
public function store(Request $request, Business $business)
{
$branding = BusinessBrandingSetting::forBusiness($business);
$validated = $request->validate([
'name' => 'required|string|max:255',
'channel' => 'required|in:email,sms',
'list_id' => 'required|exists:marketing_lists,id',
'subject' => 'required_if:channel,email|nullable|string|max:255',
'body' => 'required|string',
'send_at' => 'nullable|date|after:now',
'promo_id' => 'nullable|exists:marketing_promos,id',
]);
// Verify list belongs to this business
$list = MarketingList::where('business_id', $business->id)
->findOrFail($validated['list_id']);
// Build campaign data
$campaignData = [
'business_id' => $business->id,
'name' => $validated['name'],
'channel' => $validated['channel'],
'list_id' => $list->id,
'subject' => $validated['subject'] ?? null,
'body' => $validated['body'],
'status' => 'draft',
'created_by' => Auth::id(),
// Use branding defaults for from fields
'from_name' => $branding->effective_from_name,
'from_email' => $branding->effective_from_email,
];
// Link to promo if provided
if (! empty($validated['promo_id'])) {
$promo = MarketingPromo::where('business_id', $business->id)
->find($validated['promo_id']);
if ($promo) {
$campaignData['source_type'] = 'promo';
$campaignData['source_id'] = $promo->id;
}
}
// Set schedule if provided
if (! empty($validated['send_at'])) {
$campaignData['send_at'] = $validated['send_at'];
$campaignData['status'] = 'scheduled';
}
$campaign = MarketingCampaign::create($campaignData);
if ($campaign->status === 'scheduled') {
return redirect()
->route('portal.campaigns.show', [$business->slug, $campaign])
->with('success', 'Campaign scheduled successfully.');
}
return redirect()
->route('portal.campaigns.show', [$business->slug, $campaign])
->with('success', 'Campaign created as draft. Review and send when ready.');
}
public function show(Request $request, Business $business, MarketingCampaign $campaign)
{
// Ensure campaign belongs to this business
if ($campaign->business_id !== $business->id) {
abort(404);
}
$branding = BusinessBrandingSetting::forBusiness($business);
$campaign->load(['list', 'logs']);
// Get stats
$stats = [
'total_recipients' => $campaign->total_recipients,
'sent' => $campaign->total_sent,
'delivered' => $campaign->total_delivered,
'opened' => $campaign->total_opened,
'clicked' => $campaign->total_clicked,
'failed' => $campaign->total_failed,
];
return view('portal.campaigns.show', compact(
'business',
'branding',
'campaign',
'stats'
));
}
public function sendNow(Request $request, Business $business, MarketingCampaign $campaign)
{
// Ensure campaign belongs to this business
if ($campaign->business_id !== $business->id) {
abort(404);
}
if (! in_array($campaign->status, ['draft', 'scheduled'])) {
return back()->with('error', 'This campaign cannot be sent.');
}
// Count recipients
$recipientCount = $campaign->list?->contacts()->count() ?? 0;
if ($recipientCount === 0) {
return back()->with('error', 'No recipients in the selected list.');
}
// Update campaign
$campaign->update([
'status' => 'sending',
'total_recipients' => $recipientCount,
'sent_at' => now(),
]);
// Dispatch job
SendMarketingCampaignJob::dispatch($campaign);
return redirect()
->route('portal.campaigns.show', [$business->slug, $campaign])
->with('success', "Campaign is now sending to {$recipientCount} recipients.");
}
public function schedule(Request $request, Business $business, MarketingCampaign $campaign)
{
// Ensure campaign belongs to this business
if ($campaign->business_id !== $business->id) {
abort(404);
}
if ($campaign->status !== 'draft') {
return back()->with('error', 'Only draft campaigns can be scheduled.');
}
$validated = $request->validate([
'send_at' => 'required|date|after:now',
]);
$campaign->update([
'status' => 'scheduled',
'send_at' => $validated['send_at'],
]);
return redirect()
->route('portal.campaigns.show', [$business->slug, $campaign])
->with('success', 'Campaign scheduled for '.$campaign->send_at->format('M j, Y g:i A'));
}
public function cancel(Request $request, Business $business, MarketingCampaign $campaign)
{
// Ensure campaign belongs to this business
if ($campaign->business_id !== $business->id) {
abort(404);
}
if (! in_array($campaign->status, ['draft', 'scheduled'])) {
return back()->with('error', 'This campaign cannot be cancelled.');
}
$campaign->update([
'status' => 'cancelled',
]);
return redirect()
->route('portal.campaigns.index', $business->slug)
->with('success', 'Campaign cancelled.');
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace App\Http\Controllers\Portal;
use App\Http\Controllers\Controller;
use App\Models\Branding\BusinessBrandingSetting;
use App\Models\Business;
use App\Models\Marketing\MarketingCampaign;
use App\Models\Marketing\MarketingPromo;
use App\Services\Marketing\PromoRecommendationService;
use Illuminate\Http\Request;
class DashboardController extends Controller
{
public function __construct(
protected PromoRecommendationService $promoService
) {}
public function index(Request $request, Business $business)
{
$branding = BusinessBrandingSetting::forBusiness($business);
// Get recommended promos for this business
$recommendedPromos = collect();
try {
// Get store external IDs for this business if available
$storeExternalIds = $business->cannaiqStores()
->pluck('external_id')
->toArray();
if (! empty($storeExternalIds)) {
$recommendations = $this->promoService->getRecommendations(
$business,
$storeExternalIds[0] ?? null,
limit: 5
);
$recommendedPromos = collect($recommendations['recommendations'] ?? []);
}
} catch (\Exception $e) {
// CannaiQ not configured or error - that's fine, show empty
}
// Get recent campaigns for this business
$recentCampaigns = MarketingCampaign::where('business_id', $business->id)
->with('list')
->latest()
->limit(5)
->get();
// Get active promos
$activePromos = MarketingPromo::forBusiness($business->id)
->currentlyActive()
->with('brand')
->limit(5)
->get();
// Get campaign stats
$campaignStats = [
'total' => MarketingCampaign::where('business_id', $business->id)->count(),
'sent' => MarketingCampaign::where('business_id', $business->id)
->whereIn('status', ['sent', 'completed'])
->count(),
'draft' => MarketingCampaign::where('business_id', $business->id)
->where('status', 'draft')
->count(),
'scheduled' => MarketingCampaign::where('business_id', $business->id)
->where('status', 'scheduled')
->count(),
];
return view('portal.dashboard', compact(
'business',
'branding',
'recommendedPromos',
'recentCampaigns',
'activePromos',
'campaignStats'
));
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace App\Http\Controllers\Portal;
use App\Http\Controllers\Controller;
use App\Models\Branding\BusinessBrandingSetting;
use App\Models\Business;
use App\Models\Marketing\MarketingList;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class ListController extends Controller
{
public function index(Request $request, Business $business)
{
$branding = BusinessBrandingSetting::forBusiness($business);
$lists = MarketingList::where('business_id', $business->id)
->withCount('contacts')
->orderBy('name')
->paginate(15);
return view('portal.lists.index', compact(
'business',
'branding',
'lists'
));
}
public function create(Request $request, Business $business)
{
$branding = BusinessBrandingSetting::forBusiness($business);
$types = MarketingList::getTypes();
return view('portal.lists.create', compact(
'business',
'branding',
'types'
));
}
public function store(Request $request, Business $business)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'type' => 'required|in:static,smart',
]);
$list = MarketingList::create([
'business_id' => $business->id,
'name' => $validated['name'],
'description' => $validated['description'],
'type' => $validated['type'],
'created_by' => Auth::id(),
]);
return redirect()
->route('portal.lists.show', [$business->slug, $list])
->with('success', 'List created successfully.');
}
public function show(Request $request, Business $business, MarketingList $list)
{
// Ensure list belongs to this business
if ($list->business_id !== $business->id) {
abort(404);
}
$branding = BusinessBrandingSetting::forBusiness($business);
$contacts = $list->contacts()
->orderBy('created_at', 'desc')
->paginate(25);
return view('portal.lists.show', compact(
'business',
'branding',
'list',
'contacts'
));
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace App\Http\Controllers\Portal;
use App\Http\Controllers\Controller;
use App\Models\Branding\BusinessBrandingSetting;
use App\Models\Business;
use App\Models\Marketing\MarketingPromo;
use App\Services\Marketing\PromoRecommendationService;
use Illuminate\Http\Request;
class PromoController extends Controller
{
public function __construct(
protected PromoRecommendationService $promoService
) {}
public function index(Request $request, Business $business)
{
$branding = BusinessBrandingSetting::forBusiness($business);
// Get recommended promos from CannaiQ
$recommendedPromos = collect();
try {
$storeExternalIds = $business->cannaiqStores()
->pluck('external_id')
->toArray();
if (! empty($storeExternalIds)) {
$recommendations = $this->promoService->getRecommendations(
$business,
$storeExternalIds[0] ?? null,
limit: 20
);
$recommendedPromos = collect($recommendations['recommendations'] ?? []);
}
} catch (\Exception $e) {
// CannaiQ not available
}
// Get existing promos for this business
$existingPromos = MarketingPromo::forBusiness($business->id)
->with('brand')
->when($request->status, fn ($q, $status) => $q->where('status', $status))
->latest()
->paginate(12);
$statuses = MarketingPromo::getStatuses();
return view('portal.promos.index', compact(
'business',
'branding',
'recommendedPromos',
'existingPromos',
'statuses'
));
}
public function show(Request $request, Business $business, MarketingPromo $promo)
{
// Ensure promo belongs to this business
if ($promo->business_id !== $business->id) {
abort(404);
}
$branding = BusinessBrandingSetting::forBusiness($business);
$promo->load('brand');
return view('portal.promos.show', compact(
'business',
'branding',
'promo'
));
}
}

View File

@@ -80,7 +80,7 @@ class BatchController extends Controller
->where('quantity_available', '>', 0)
->where('is_active', true)
->where('is_quarantined', false)
->with('component')
->with('product')
->orderBy('batch_number')
->get()
->map(function ($batch) {
@@ -102,17 +102,28 @@ class BatchController extends Controller
$maxValue = ($request->cannabinoid_unit ?? '%') === '%' ? 100 : 1000;
$validated = $request->validate([
'product_id' => 'required|exists:products,id',
// Accept either product_id or component_id (form sends component_id)
'product_id' => 'required_without:component_id|exists:products,id',
'component_id' => 'required_without:product_id|exists:products,id',
'batch_type' => 'nullable|string|in:component,homogenized',
'cannabinoid_unit' => 'nullable|string|in:%,MG/ML,MG/G,MG/UNIT',
'batch_number' => 'nullable|string|max:100|unique:batches,batch_number',
'quantity_produced' => 'nullable|integer|min:0',
'batch_number' => 'required|string|max:100|unique:batches,batch_number',
'internal_code' => 'nullable|string|max:100',
// Accept either quantity_produced or quantity_total (form sends quantity_total)
'quantity_produced' => 'nullable|numeric|min:0',
'quantity_total' => 'nullable|numeric|min:0',
'quantity_remaining' => 'nullable|numeric|min:0',
'quantity_unit' => 'nullable|string|max:50',
'quantity_allocated' => 'nullable|integer|min:0',
'expiration_date' => 'nullable|date',
'is_active' => 'nullable|boolean',
'is_active' => 'nullable',
'production_date' => 'nullable|date',
'harvest_date' => 'nullable|date',
'package_date' => 'nullable|date',
'test_date' => 'nullable|date',
'test_id' => 'nullable|string|max:100',
'lot_number' => 'nullable|string|max:100',
'license_number' => 'nullable|string|max:255',
'lab_name' => 'nullable|string|max:255',
'thc_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
'thca_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
@@ -126,10 +137,18 @@ class BatchController extends Controller
'coa_files.*' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240', // 10MB max per file
]);
// Map component_id to product_id if provided
$productId = $validated['product_id'] ?? $validated['component_id'];
// Verify product belongs to this business
$product = Product::whereHas('brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})->findOrFail($validated['product_id']);
})->findOrFail($productId);
// Map form fields to model fields
$validated['product_id'] = $productId;
$validated['quantity_produced'] = $validated['quantity_total'] ?? $validated['quantity_produced'] ?? 0;
$validated['quantity_available'] = $validated['quantity_remaining'] ?? $validated['quantity_produced'];
// Set business_id and defaults
$validated['business_id'] = $business->id;

View File

@@ -9,12 +9,14 @@ use App\Http\Requests\UpdateBrandRequest;
use App\Models\Brand;
use App\Models\BrandOrchestratorProfile;
use App\Models\Business;
use App\Models\Crm\CrmChannel;
use App\Models\Menu;
use App\Models\OrchestratorTask;
use App\Models\PromoRecommendation;
use App\Models\Promotion;
use App\Services\Promo\InBrandPromoHelper;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
@@ -42,7 +44,29 @@ class BrandController extends Controller
->orderBy('name')
->get();
return view('seller.brands.index', compact('business', 'brands'));
// Pre-compute expensive operations for Alpine.js (prevents N+1 route() calls in Blade)
$brandsJson = $brands->filter(fn ($brand) => $brand->hashid)->map(function ($brand) use ($business) {
return [
'id' => $brand->id,
'hashid' => $brand->hashid,
'name' => $brand->name,
'tagline' => $brand->tagline,
'logo_url' => $brand->hasLogo() ? $brand->getLogoUrl(160) : null,
'is_active' => $brand->is_active,
'is_public' => $brand->is_public,
'is_featured' => $brand->is_featured,
'products_count' => $brand->products_count ?? 0,
'updated_at' => $brand->updated_at?->diffForHumans(),
'website_url' => $brand->website_url,
'preview_url' => route('seller.business.brands.preview', [$business->slug, $brand]),
'dashboard_url' => route('seller.business.brands.dashboard', [$business->slug, $brand]),
'stats_url' => route('seller.business.brands.stats', [$business->slug, $brand]),
'edit_url' => route('seller.business.brands.edit', [$business->slug, $brand]),
'isNewBrand' => $brand->created_at && $brand->created_at->diffInDays(now()) <= 30,
];
})->values();
return view('seller.brands.index', compact('business', 'brands', 'brandsJson'));
}
/**
@@ -145,121 +169,179 @@ class BrandController extends Controller
{
$this->authorize('view', [$brand, $business]);
// Load relationships
// Determine active tab - only load data for that tab
$activeTab = $request->input('tab', 'overview');
// Load minimal brand data with products for metrics display
$brand->load(['business', 'products']);
// Get stats data for Analytics tab (default to this month)
$preset = $request->input('preset', 'this_month');
$startDate = null;
$endDate = null;
switch ($preset) {
case 'this_week':
$startDate = now()->startOfWeek();
$endDate = now()->endOfWeek();
break;
case 'last_week':
$startDate = now()->subWeek()->startOfWeek();
$endDate = now()->subWeek()->endOfWeek();
break;
case 'this_month':
$startDate = now()->startOfMonth();
$endDate = now()->endOfMonth();
break;
case 'last_month':
$startDate = now()->subMonth()->startOfMonth();
$endDate = now()->subMonth()->endOfMonth();
break;
case 'this_year':
$startDate = now()->startOfYear();
$endDate = now()->endOfYear();
break;
case 'custom':
$startDate = $request->input('start_date') ? \Carbon\Carbon::parse($request->input('start_date'))->startOfDay() : now()->startOfMonth();
$endDate = $request->input('end_date') ? \Carbon\Carbon::parse($request->input('end_date'))->endOfDay() : now();
break;
case 'all_time':
default:
// Query from earliest order for this brand, or default to brand creation date if no orders
$earliestOrder = \App\Models\Order::whereHas('items.product', function ($query) use ($brand) {
$query->where('brand_id', $brand->id);
})->oldest('created_at')->first();
// If no orders, use the brand's creation date as the starting point
$startDate = $earliestOrder
? $earliestOrder->created_at->startOfDay()
: ($brand->created_at ? $brand->created_at->startOfDay() : now()->subYears(3)->startOfDay());
$endDate = now()->endOfDay();
break;
}
// Calculate stats for analytics tab
$stats = $this->calculateBrandStats($brand, $startDate, $endDate);
// Load promotions filtered by brand
$promotions = Promotion::where('business_id', $business->id)
->where('brand_id', $brand->id)
->withCount('products')
->orderBy('created_at', 'desc')
->get();
// Load upcoming promotions (scheduled within next 7 days)
$upcomingPromotions = Promotion::where('business_id', $business->id)
->where('brand_id', $brand->id)
->upcomingWithinDays(7)
->withCount('products')
->orderBy('starts_at', 'asc')
->get();
// Load active promotions for quick display
$activePromotions = Promotion::where('business_id', $business->id)
->where('brand_id', $brand->id)
->active()
->withCount('products')
->orderBy('ends_at', 'asc')
->get();
// Load menus filtered by brand
$menus = Menu::where('business_id', $business->id)
->where('brand_id', $brand->id)
->withCount('products')
->orderBy('created_at', 'desc')
->get();
// Load promo recommendations for this brand
$recommendations = PromoRecommendation::where('business_id', $business->id)
->where('brand_id', $brand->id)
->pending()
->notExpired()
->with(['product'])
->orderByRaw("
CASE
WHEN priority = 'high' THEN 1
WHEN priority = 'medium' THEN 2
WHEN priority = 'low' THEN 3
ELSE 4
END
")
->orderByDesc('confidence')
->get();
// Load all brands for the brand selector dropdown
// Load all brands for the brand selector dropdown (lightweight, always needed)
$brands = $business->brands()
->where('is_active', true)
->withCount('products')
->orderBy('name')
->get();
// Load products for this brand (newest first) with pagination
// Get date range for stats (used by overview and analytics)
$preset = $request->input('preset', 'this_month');
[$startDate, $endDate] = $this->getDateRangeForPreset($preset, $request, $brand);
// Initialize empty data - will be populated based on active tab
$viewData = [
'business' => $business,
'brand' => $brand,
'brands' => $brands,
'preset' => $preset,
'startDate' => $startDate,
'endDate' => $endDate,
'activeTab' => $activeTab,
// Empty defaults for all tab data
'promotions' => collect(),
'activePromotions' => collect(),
'upcomingPromotions' => collect(),
'recommendations' => collect(),
'menus' => collect(),
'products' => collect(),
'productsPagination' => [],
'productsPaginator' => null,
'collections' => collect(),
'brandInsights' => [],
// Empty stats defaults
'totalOrders' => 0,
'totalRevenue' => 0,
'totalUnits' => 0,
'avgOrderValue' => 0,
'totalProducts' => 0,
'activeProducts' => 0,
'revenueChange' => 0,
'ordersChange' => 0,
'revenueByDay' => collect(),
'productStats' => collect(),
'bestSellingSku' => null,
'topBuyers' => collect(),
];
// Load data based on active tab
switch ($activeTab) {
case 'overview':
$viewData = array_merge($viewData, $this->loadOverviewTabData($brand, $business, $startDate, $endDate));
break;
case 'products':
$viewData = array_merge($viewData, $this->loadProductsTabData($brand, $business, $request));
break;
case 'promotions':
$viewData = array_merge($viewData, $this->loadPromotionsTabData($brand, $business));
break;
case 'menus':
$viewData = array_merge($viewData, $this->loadMenusTabData($brand, $business));
break;
case 'analytics':
$viewData = array_merge($viewData, $this->loadAnalyticsTabData($brand, $business, $startDate, $endDate, $preset));
break;
case 'settings':
case 'storefront':
case 'collections':
// These tabs don't need additional data loading
break;
}
return view('seller.brands.dashboard', $viewData);
}
/**
* Get date range based on preset selection.
*/
private function getDateRangeForPreset(string $preset, Request $request, Brand $brand): array
{
switch ($preset) {
case 'this_week':
return [now()->startOfWeek(), now()->endOfWeek()];
case 'last_week':
return [now()->subWeek()->startOfWeek(), now()->subWeek()->endOfWeek()];
case 'this_month':
return [now()->startOfMonth(), now()->endOfMonth()];
case 'last_month':
return [now()->subMonth()->startOfMonth(), now()->subMonth()->endOfMonth()];
case 'this_year':
return [now()->startOfYear(), now()->endOfYear()];
case 'custom':
$startDate = $request->input('start_date') ? \Carbon\Carbon::parse($request->input('start_date'))->startOfDay() : now()->startOfMonth();
$endDate = $request->input('end_date') ? \Carbon\Carbon::parse($request->input('end_date'))->endOfDay() : now();
return [$startDate, $endDate];
case 'all_time':
default:
$earliestOrder = \App\Models\Order::whereHas('items.product', function ($query) use ($brand) {
$query->where('brand_id', $brand->id);
})->oldest('created_at')->first();
$startDate = $earliestOrder
? $earliestOrder->created_at->startOfDay()
: ($brand->created_at ? $brand->created_at->startOfDay() : now()->subYears(3)->startOfDay());
return [$startDate, now()->endOfDay()];
}
}
/**
* Load data for Overview tab (lightweight stats + insights).
*/
private function loadOverviewTabData(Brand $brand, Business $business, $startDate, $endDate): array
{
// Cache brand insights for 15 minutes
$cacheKey = "brand:{$brand->id}:insights:{$startDate->format('Y-m-d')}:{$endDate->format('Y-m-d')}";
$brandInsights = Cache::remember($cacheKey, 900, fn () => $this->calculateBrandInsights($brand, $business, $startDate, $endDate));
// Load active promotions for quick display (lightweight)
$activePromotions = Promotion::where('business_id', $business->id)
->where('brand_id', $brand->id)
->active()
->withCount('products')
->orderBy('ends_at', 'asc')
->limit(5)
->get();
// Load recommendations (lightweight - limit to 5)
$recommendations = PromoRecommendation::where('business_id', $business->id)
->where('brand_id', $brand->id)
->pending()
->notExpired()
->with(['product'])
->orderByRaw("CASE WHEN priority = 'high' THEN 1 WHEN priority = 'medium' THEN 2 WHEN priority = 'low' THEN 3 ELSE 4 END")
->orderByDesc('confidence')
->limit(5)
->get();
// Get basic counts (very fast single query)
$productCounts = $brand->products()
->selectRaw('COUNT(*) as total, SUM(CASE WHEN is_active = true THEN 1 ELSE 0 END) as active')
->first();
return [
'brandInsights' => $brandInsights,
'activePromotions' => $activePromotions,
'recommendations' => $recommendations,
'totalProducts' => $productCounts->total ?? 0,
'activeProducts' => $productCounts->active ?? 0,
];
}
/**
* Load data for Products tab.
*/
private function loadProductsTabData(Brand $brand, Business $business, Request $request): array
{
$perPage = $request->get('per_page', 50);
$productsPaginator = $brand->products()
->whereNotNull('hashid')
->where('hashid', '!=', '')
->with('images')
->orderBy('created_at', 'desc')
->paginate($perPage);
$products = $productsPaginator->getCollection()
->filter(fn ($product) => ! empty($product->hashid))
->map(function ($product) use ($business, $brand) {
// Set brand relationship so getImageUrl() can fall back to brand logo
$product->setRelation('brand', $brand);
return [
@@ -275,35 +357,101 @@ class BrandController extends Controller
'edit_url' => route('seller.business.products.edit', [$business->slug, $product->hashid]),
'preview_url' => route('seller.business.products.preview', [$business->slug, $product->hashid]),
];
});
})
->values();
// Pagination info for the view
$productsPagination = [
'current_page' => $productsPaginator->currentPage(),
'last_page' => $productsPaginator->lastPage(),
'per_page' => $productsPaginator->perPage(),
'total' => $productsPaginator->total(),
'from' => $productsPaginator->firstItem(),
'to' => $productsPaginator->lastItem(),
];
return view('seller.brands.dashboard', array_merge($stats, [
'business' => $business,
'brand' => $brand,
'brands' => $brands,
'preset' => $preset,
'startDate' => $startDate,
'endDate' => $endDate,
'promotions' => $promotions,
'activePromotions' => $activePromotions,
'upcomingPromotions' => $upcomingPromotions,
'recommendations' => $recommendations,
'menus' => $menus,
return [
'products' => $products,
'productsPagination' => $productsPagination,
'productsPagination' => [
'current_page' => $productsPaginator->currentPage(),
'last_page' => $productsPaginator->lastPage(),
'per_page' => $productsPaginator->perPage(),
'total' => $productsPaginator->total(),
'from' => $productsPaginator->firstItem(),
'to' => $productsPaginator->lastItem(),
],
'productsPaginator' => $productsPaginator,
'collections' => collect(), // Placeholder for future collections feature
]));
];
}
/**
* Load data for Promotions tab.
*/
private function loadPromotionsTabData(Brand $brand, Business $business): array
{
$promotions = Promotion::where('business_id', $business->id)
->where('brand_id', $brand->id)
->withCount('products')
->orderBy('created_at', 'desc')
->get();
$upcomingPromotions = Promotion::where('business_id', $business->id)
->where('brand_id', $brand->id)
->upcomingWithinDays(7)
->withCount('products')
->orderBy('starts_at', 'asc')
->get();
$activePromotions = Promotion::where('business_id', $business->id)
->where('brand_id', $brand->id)
->active()
->withCount('products')
->orderBy('ends_at', 'asc')
->get();
return [
'promotions' => $promotions,
'upcomingPromotions' => $upcomingPromotions,
'activePromotions' => $activePromotions,
];
}
/**
* Load data for Menus tab.
*/
private function loadMenusTabData(Brand $brand, Business $business): array
{
$menus = Menu::where('business_id', $business->id)
->where('brand_id', $brand->id)
->withCount('products')
->orderBy('created_at', 'desc')
->get();
return ['menus' => $menus];
}
/**
* Load data for Analytics tab (cached for 15 minutes).
*/
private function loadAnalyticsTabData(Brand $brand, Business $business, $startDate, $endDate, string $preset): array
{
// Cache stats for 15 minutes (keyed by brand + date range)
$cacheKey = "brand:{$brand->id}:stats:{$preset}:{$startDate->format('Y-m-d')}:{$endDate->format('Y-m-d')}";
return Cache::remember($cacheKey, 900, fn () => $this->calculateBrandStats($brand, $startDate, $endDate));
}
/**
* API endpoint for lazy-loading tab data via AJAX.
*/
public function tabData(Request $request, Business $business, Brand $brand)
{
$this->authorize('view', [$brand, $business]);
$tab = $request->input('tab', 'overview');
$preset = $request->input('preset', 'this_month');
[$startDate, $endDate] = $this->getDateRangeForPreset($preset, $request, $brand);
$data = match ($tab) {
'overview' => $this->loadOverviewTabData($brand, $business, $startDate, $endDate),
'products' => $this->loadProductsTabData($brand, $business, $request),
'promotions' => $this->loadPromotionsTabData($brand, $business),
'menus' => $this->loadMenusTabData($brand, $business),
'analytics' => $this->loadAnalyticsTabData($brand, $business, $startDate, $endDate, $preset),
default => [],
};
return response()->json($data);
}
/**
@@ -359,7 +507,14 @@ class BrandController extends Controller
{
$this->authorize('update', [$brand, $business]);
return view('seller.brands.edit', compact('business', 'brand'));
// Get available email channels for CRM inbound routing
$emailChannels = CrmChannel::forBusiness($business->id)
->where('type', CrmChannel::TYPE_EMAIL)
->where('is_active', true)
->orderBy('name')
->get();
return view('seller.brands.edit', compact('business', 'brand', 'emailChannels'));
}
/**
@@ -456,6 +611,19 @@ class BrandController extends Controller
$brand->inbound_email = $request->input('inbound_email');
$brand->sms_number = $request->input('sms_number');
// CRM Channel Assignment (validate channel belongs to this business)
if ($request->has('inbound_email_channel_id')) {
$channelId = $request->input('inbound_email_channel_id');
if ($channelId) {
$channel = CrmChannel::where('business_id', $business->id)
->where('id', $channelId)
->first();
$validated['inbound_email_channel_id'] = $channel ? $channel->id : null;
} else {
$validated['inbound_email_channel_id'] = null;
}
}
// Update brand
$brand->update($validated);
@@ -1711,4 +1879,214 @@ class BrandController extends Controller
->route('seller.business.brands.index', $business->slug)
->with('success', 'Brand deleted successfully!');
}
/**
* Calculate lightweight brand insights for the dashboard
*/
private function calculateBrandInsights(Brand $brand, Business $business, $startDate, $endDate): array
{
// Eager load images to avoid N+1 and lazy loading errors
$products = $brand->products()->with('images')->get();
// Top Performer - product with highest revenue in date range
$topPerformer = null;
$topPerformerData = \App\Models\Order::whereHas('items.product', function ($query) use ($brand) {
$query->where('brand_id', $brand->id);
})
->whereBetween('created_at', [$startDate, $endDate])
->whereIn('status', ['confirmed', 'completed', 'shipped', 'delivered'])
->with(['items.product' => function ($query) use ($brand) {
$query->where('brand_id', $brand->id);
}])
->get()
->flatMap(function ($order) use ($brand) {
return $order->items->filter(function ($item) use ($brand) {
return $item->product && $item->product->brand_id === $brand->id;
});
})
->groupBy('product_id')
->map(function ($items) {
$product = $items->first()->product;
return [
'product' => $product,
'revenue' => $items->sum(function ($item) {
return $item->quantity * $item->price;
}),
'orders' => $items->count(),
];
})
->sortByDesc('revenue')
->first();
if ($topPerformerData) {
$topPerformer = [
'name' => $topPerformerData['product']->name,
'hashid' => $topPerformerData['product']->hashid,
'revenue' => $topPerformerData['revenue'],
'orders' => $topPerformerData['orders'],
];
}
// Needs Attention - aggregate counts for quick issues
$missingImages = $products->filter(fn ($p) => empty($p->image_path) && $p->images->isEmpty())->count();
$hiddenProducts = $products->filter(fn ($p) => ! $p->is_active)->count();
$draftProducts = $products->filter(fn ($p) => $p->status === 'draft')->count();
// Note: Out of stock would require inventory data - hardcoded to 0 for now
$outOfStock = 0;
$totalIssues = $missingImages + $hiddenProducts + $draftProducts + $outOfStock;
// Visibility Issues - hidden + draft count
$visibilityIssues = $hiddenProducts + $draftProducts;
return [
'topPerformer' => $topPerformer,
'needsAttention' => [
'total' => $totalIssues,
'missingImages' => $missingImages,
'hiddenProducts' => $hiddenProducts,
'draftProducts' => $draftProducts,
'outOfStock' => $outOfStock,
],
'visibilityIssues' => $visibilityIssues,
];
}
/**
* Display brand market analysis / intelligence page.
*
* v4 endpoint with optional store_id filtering for per-store projections.
*/
public function analysis(Request $request, Business $business, Brand $brand)
{
$this->authorize('view', [$brand, $business]);
// CannaiQ must be enabled to access Brand Analysis
if (! $business->cannaiq_enabled) {
return view('seller.brands.analysis-disabled', [
'business' => $business,
'brand' => $brand,
]);
}
// v4: Get optional store_id filter for shelf value projections
$storeId = $request->query('store_id');
$analysisService = app(\App\Services\Cannaiq\BrandAnalysisService::class);
$analysis = $analysisService->getAnalysis($brand, $business, $storeId);
// Load all brands for the brand selector
$brands = $business->brands()
->where('is_active', true)
->withCount('products')
->orderBy('name')
->get();
// Build store list from placement data for store selector
$storeList = [];
if ((bool) $business->cannaiq_enabled) {
$placementStores = $analysis->placement['stores'] ?? $analysis->placement ?? [];
$whitespaceStores = $analysis->placement['whitespaceStores'] ?? [];
foreach ($placementStores as $store) {
$storeList[] = [
'id' => $store['storeId'] ?? '',
'name' => $store['storeName'] ?? 'Unknown',
'state' => $store['state'] ?? null,
];
}
foreach ($whitespaceStores as $store) {
$storeList[] = [
'id' => $store['storeId'] ?? '',
'name' => $store['storeName'] ?? 'Unknown',
'state' => $store['state'] ?? null,
];
}
}
return view('seller.brands.analysis', [
'business' => $business,
'brand' => $brand,
'brands' => $brands,
'analysis' => $analysis,
'cannaiqEnabled' => (bool) $business->cannaiq_enabled,
'storeList' => $storeList,
'selectedStoreId' => $storeId,
]);
}
/**
* Refresh brand analysis data (clears cache and re-fetches).
*/
public function analysisRefresh(Request $request, Business $business, Brand $brand)
{
$this->authorize('view', [$brand, $business]);
// CannaiQ must be enabled to refresh analysis
if (! $business->cannaiq_enabled) {
if ($request->wantsJson()) {
return response()->json([
'success' => false,
'message' => 'CannaiQ is not enabled for this business. Please contact support.',
], 403);
}
return back()->with('error', 'CannaiQ is not enabled for this business.');
}
$analysisService = app(\App\Services\Cannaiq\BrandAnalysisService::class);
$analysis = $analysisService->refreshAnalysis($brand, $business);
if ($request->wantsJson()) {
return response()->json([
'success' => true,
'message' => 'Analysis data refreshed',
'data' => $analysis->toArray(),
]);
}
return redirect()
->route('seller.business.brands.analysis', [$business->slug, $brand->hashid])
->with('success', 'Analysis data refreshed successfully');
}
/**
* Get store-level playbook for a specific store.
*
* Returns targeted recommendations for a single retail account.
*/
public function storePlaybook(Request $request, Business $business, Brand $brand, string $storeId)
{
$this->authorize('view', [$brand, $business]);
if (! $business->cannaiq_enabled) {
if ($request->wantsJson()) {
return response()->json([
'success' => false,
'message' => 'CannaiQ is not enabled for this business',
], 403);
}
return back()->with('error', 'CannaiQ is not enabled for this business');
}
$analysisService = app(\App\Services\Cannaiq\BrandAnalysisService::class);
$playbook = $analysisService->getStorePlaybook($brand, $business, $storeId);
if ($request->wantsJson()) {
return response()->json([
'success' => true,
'data' => $playbook,
]);
}
// For non-JSON requests, redirect to analysis page with store selected
return redirect()
->route('seller.business.brands.analysis', [
$business->slug,
$brand->hashid,
'store_id' => $storeId,
]);
}
}

View File

@@ -18,12 +18,22 @@ class BrandSwitcherController extends Controller
{
$brandId = $request->input('brand_id');
$brandHashid = $request->input('brand_hashid');
$redirectTo = $request->input('redirect_to');
// If both are empty, clear the session (show all brands)
if (empty($brandId) && empty($brandHashid)) {
// Clear cache for current user before removing session
$user = auth()->user();
$business = $user?->primaryBusiness();
$oldBrandId = session('selected_brand_id');
if ($user && $business && $oldBrandId) {
\Illuminate\Support\Facades\Cache::forget("selected_brand:{$user->id}:{$business->id}:{$oldBrandId}");
}
session()->forget('selected_brand_id');
return back();
return $redirectTo ? redirect($redirectTo) : back();
}
// Verify the brand exists and belongs to user's business

View File

@@ -14,12 +14,17 @@ class ContactController extends Controller
{
/**
* Display a listing of contacts (CRM Core)
* Shows all contacts who have interacted with this seller business
* Shows all contacts from buyer businesses (accounts)
*/
public function index(Request $request, Business $business)
{
// Get all contact IDs that have interacted with this business
// through orders, conversations, or messages
// Get all contacts from buyer businesses (accounts)
// This gives a complete view of all contacts in the CRM
$query = Contact::whereHas('business', function ($q) {
$q->where('type', 'buyer');
})->with(['business', 'user']);
// Also track which contacts have engaged for stats
$orderContactIds = Order::whereHas('items.product.brand', function ($q) use ($business) {
$q->where('business_id', $business->id);
})->whereNotNull('contact_id')->pluck('contact_id');
@@ -28,11 +33,7 @@ class ContactController extends Controller
->whereNotNull('primary_contact_id')
->pluck('primary_contact_id');
$contactIds = $orderContactIds->merge($conversationContactIds)->unique();
// Build query
$query = Contact::whereIn('id', $contactIds)
->with(['business', 'user']);
$engagedContactIds = $orderContactIds->merge($conversationContactIds)->unique();
// Search filter
if ($request->filled('search')) {
@@ -60,6 +61,8 @@ class ContactController extends Controller
$query->whereIn('id', $orderContactIds);
} elseif ($request->activity === 'has_conversations') {
$query->whereIn('id', $conversationContactIds);
} elseif ($request->activity === 'engaged') {
$query->whereIn('id', $engagedContactIds);
}
}
@@ -75,12 +78,14 @@ class ContactController extends Controller
$contacts = $query->paginate(20)->withQueryString();
// Get stats
// Get stats - count all buyer contacts and engaged contacts
$allBuyerContactsQuery = Contact::whereHas('business', fn ($q) => $q->where('type', 'buyer'));
$stats = [
'total' => Contact::whereIn('id', $contactIds)->count(),
'active' => Contact::whereIn('id', $contactIds)->where('is_active', true)->count(),
'with_orders' => Contact::whereIn('id', $orderContactIds)->count(),
'with_conversations' => Contact::whereIn('id', $conversationContactIds)->count(),
'total' => (clone $allBuyerContactsQuery)->count(),
'active' => (clone $allBuyerContactsQuery)->where('is_active', true)->count(),
'with_orders' => $orderContactIds->count(),
'with_conversations' => $conversationContactIds->count(),
'engaged' => $engagedContactIds->count(),
];
return view('seller.contacts.index', compact('business', 'contacts', 'stats'));

View File

@@ -5,8 +5,11 @@ namespace App\Http\Controllers\Seller\Crm;
use App\Http\Controllers\Controller;
use App\Models\Activity;
use App\Models\Business;
use App\Models\Contact;
use App\Models\Crm\CrmEvent;
use App\Models\Crm\CrmQuote;
use App\Models\Crm\CrmTask;
use App\Models\Invoice;
use App\Models\SalesOpportunity;
use App\Models\SendMenuLog;
use Illuminate\Http\Request;
@@ -18,15 +21,154 @@ class AccountController extends Controller
*/
public function index(Request $request, Business $business)
{
$accounts = Business::where('type', 'buyer')
->where('status', 'approved')
->with(['contacts'])
->orderBy('name')
->paginate(25);
$query = Business::where('type', 'buyer')
->with(['contacts']);
// Search filter
if ($request->filled('q')) {
$search = $request->q;
$query->where(function ($q) use ($search) {
$q->where('name', 'ILIKE', "%{$search}%")
->orWhere('dba_name', 'ILIKE', "%{$search}%")
->orWhere('business_email', 'ILIKE', "%{$search}%");
});
}
// 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');
}
$accounts = $query->orderBy('name')->paginate(25);
// Return JSON for AJAX/API requests (live search)
if ($request->wantsJson()) {
return response()->json([
'data' => $accounts->map(fn ($a) => [
'slug' => $a->slug,
'name' => $a->name,
'email' => $a->business_email,
'status' => $a->status,
])->values()->toArray(),
]);
}
return view('seller.crm.accounts.index', compact('business', 'accounts'));
}
/**
* Show create customer form
*/
public function create(Request $request, Business $business)
{
return view('seller.crm.accounts.create', compact('business'));
}
/**
* Store a new customer (buyer business)
*/
public function store(Request $request, Business $business)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'dba_name' => 'nullable|string|max:255',
'license_number' => 'nullable|string|max:100',
'business_email' => 'nullable|email|max:255',
'business_phone' => 'nullable|string|max:50',
'physical_address' => 'nullable|string|max:255',
'physical_city' => 'nullable|string|max:100',
'physical_state' => 'nullable|string|max:50',
'physical_zipcode' => 'nullable|string|max:20',
'contact_name' => 'nullable|string|max:255',
'contact_email' => 'nullable|email|max:255',
'contact_phone' => 'nullable|string|max:50',
'contact_title' => 'nullable|string|max:100',
]);
// Create the buyer business
$account = Business::create([
'name' => $validated['name'],
'dba_name' => $validated['dba_name'] ?? null,
'license_number' => $validated['license_number'] ?? null,
'business_email' => $validated['business_email'] ?? null,
'business_phone' => $validated['business_phone'] ?? null,
'physical_address' => $validated['physical_address'] ?? null,
'physical_city' => $validated['physical_city'] ?? null,
'physical_state' => $validated['physical_state'] ?? null,
'physical_zipcode' => $validated['physical_zipcode'] ?? null,
'type' => 'buyer',
'status' => 'approved', // Auto-approve customers created by sellers
]);
// Create contact if provided
if (! empty($validated['contact_name'])) {
$account->contacts()->create([
'first_name' => explode(' ', $validated['contact_name'])[0],
'last_name' => implode(' ', array_slice(explode(' ', $validated['contact_name']), 1)) ?: null,
'email' => $validated['contact_email'] ?? null,
'phone' => $validated['contact_phone'] ?? null,
'title' => $validated['contact_title'] ?? null,
]);
}
// Log the creation event
CrmEvent::log(
sellerBusinessId: $business->id,
eventType: 'account_created',
summary: "Customer {$account->name} created",
buyerBusinessId: $account->id,
userId: auth()->id(),
channel: 'system'
);
// Return JSON for AJAX requests
if ($request->expectsJson()) {
return response()->json([
'id' => $account->id,
'name' => $account->name,
'slug' => $account->slug,
]);
}
return redirect()
->route('seller.business.crm.accounts.show', [$business->slug, $account->slug])
->with('success', 'Customer created successfully.');
}
/**
* Show edit customer form
*/
public function edit(Request $request, Business $business, Business $account)
{
return view('seller.crm.accounts.edit', compact('business', 'account'));
}
/**
* Update a customer (buyer business)
*/
public function update(Request $request, Business $business, Business $account)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'dba_name' => 'nullable|string|max:255',
'license_number' => 'nullable|string|max:100',
'business_email' => 'nullable|email|max:255',
'business_phone' => 'nullable|string|max:50',
'physical_address' => 'nullable|string|max:255',
'physical_city' => 'nullable|string|max:100',
'physical_state' => 'nullable|string|max:50',
'physical_zipcode' => 'nullable|string|max:20',
]);
$account->update($validated);
return redirect()
->route('seller.business.crm.accounts.show', [$business->slug, $account->slug])
->with('success', 'Customer updated successfully.');
}
/**
* Show account details
*/
@@ -34,17 +176,37 @@ class AccountController extends Controller
{
$account->load(['contacts']);
// Get orders for this account from this seller
// Get orders for this account from this seller (with invoices)
$orders = $account->orders()
->whereHas('items.product.brand', function ($q) use ($business) {
$q->where('business_id', $business->id);
})
->with(['invoice'])
->latest()
->limit(10)
->get();
// Get quotes for this account
$quotes = CrmQuote::where('business_id', $business->id)
->where('account_id', $account->id)
->with(['contact', 'items'])
->latest()
->limit(10)
->get();
// Get invoices for this account (via orders)
$invoices = Invoice::whereHas('order', function ($q) use ($business, $account) {
$q->where('business_id', $account->id)
->whereHas('items.product.brand', function ($q2) use ($business) {
$q2->where('business_id', $business->id);
});
})
->with(['order', 'payments'])
->latest()
->limit(10)
->get();
// Get opportunities for this account from this seller
// SalesOpportunity uses business_id for the buyer
$opportunities = SalesOpportunity::where('seller_business_id', $business->id)
->where('business_id', $account->id)
->with(['stage', 'brand'])
@@ -52,7 +214,6 @@ class AccountController extends Controller
->get();
// Get tasks related to this account
// CrmTask uses business_id for the buyer
$tasks = CrmTask::where('seller_business_id', $business->id)
->where('business_id', $account->id)
->whereNull('completed_at')
@@ -98,6 +259,31 @@ class AccountController extends Controller
->selectRaw('COUNT(*) as open_count, COALESCE(SUM(value), 0) as pipeline_value')
->first();
// Financial stats from invoices
$financialStats = Invoice::whereHas('order', function ($q) use ($business, $account) {
$q->where('business_id', $account->id)
->whereHas('items.product.brand', function ($q2) use ($business) {
$q2->where('business_id', $business->id);
});
})
->selectRaw('
COALESCE(SUM(amount_due), 0) as outstanding_balance,
COALESCE(SUM(CASE WHEN due_date < CURRENT_DATE AND amount_due > 0 THEN amount_due ELSE 0 END), 0) as past_due_amount,
COUNT(CASE WHEN amount_due > 0 THEN 1 END) as open_invoice_count,
MIN(CASE WHEN due_date < CURRENT_DATE AND amount_due > 0 THEN due_date END) as oldest_past_due_date
')
->first();
// Get last payment info
$lastPayment = \App\Models\InvoicePayment::whereHas('invoice.order', function ($q) use ($business, $account) {
$q->where('business_id', $account->id)
->whereHas('items.product.brand', function ($q2) use ($business) {
$q2->where('business_id', $business->id);
});
})
->latest('payment_date')
->first();
$stats = [
'total_orders' => $orderStats->total_orders ?? 0,
'total_revenue' => $orderStats->total_revenue ?? 0,
@@ -105,11 +291,25 @@ class AccountController extends Controller
'pipeline_value' => $opportunityStats->pipeline_value ?? 0,
];
$financials = [
'outstanding_balance' => $financialStats->outstanding_balance ?? 0,
'past_due_amount' => $financialStats->past_due_amount ?? 0,
'open_invoice_count' => $financialStats->open_invoice_count ?? 0,
'oldest_past_due_days' => $financialStats->oldest_past_due_date
? now()->diffInDays($financialStats->oldest_past_due_date)
: null,
'last_payment_amount' => $lastPayment->amount ?? null,
'last_payment_date' => $lastPayment->payment_date ?? null,
];
return view('seller.crm.accounts.show', compact(
'business',
'account',
'stats',
'financials',
'orders',
'quotes',
'invoices',
'opportunities',
'tasks',
'conversationEvents',
@@ -141,7 +341,15 @@ class AccountController extends Controller
*/
public function orders(Request $request, Business $business, Business $account)
{
return view('seller.crm.accounts.orders', compact('business', 'account'));
$orders = $account->orders()
->whereHas('items.product.brand', function ($q) use ($business) {
$q->where('business_id', $business->id);
})
->with(['items.product.brand'])
->latest()
->paginate(25);
return view('seller.crm.accounts.orders', compact('business', 'account', 'orders'));
}
/**
@@ -149,7 +357,13 @@ class AccountController extends Controller
*/
public function activity(Request $request, Business $business, Business $account)
{
return view('seller.crm.accounts.activity', compact('business', 'account'));
$activities = Activity::where('seller_business_id', $business->id)
->where('business_id', $account->id)
->with(['causer'])
->latest()
->paginate(50);
return view('seller.crm.accounts.activity', compact('business', 'account', 'activities'));
}
/**
@@ -182,4 +396,95 @@ class AccountController extends Controller
->route('seller.business.crm.accounts.show', [$business->slug, $account->slug])
->with('success', 'Note added successfully.');
}
/**
* Store a new contact for an account
*/
public function storeContact(Request $request, Business $business, Business $account)
{
$validated = $request->validate([
'first_name' => 'required|string|max:100',
'last_name' => 'nullable|string|max:100',
'email' => 'nullable|email|max:255',
'phone' => 'nullable|string|max:50',
'title' => 'nullable|string|max:100',
]);
$contact = $account->contacts()->create($validated);
// Return JSON for AJAX requests
if ($request->expectsJson()) {
return response()->json([
'id' => $contact->id,
'first_name' => $contact->first_name,
'last_name' => $contact->last_name,
'email' => $contact->email,
'phone' => $contact->phone,
'title' => $contact->title,
]);
}
return redirect()
->route('seller.business.crm.accounts.contacts', [$business->slug, $account->slug])
->with('success', 'Contact added successfully.');
}
/**
* Show edit contact form
*/
public function editContact(Request $request, Business $business, Business $account, Contact $contact)
{
// Verify contact belongs to this account
if ($contact->business_id !== $account->id) {
abort(404);
}
return view('seller.crm.accounts.contacts-edit', compact('business', 'account', 'contact'));
}
/**
* Update a contact
*/
public function updateContact(Request $request, Business $business, Business $account, Contact $contact)
{
// Verify contact belongs to this account
if ($contact->business_id !== $account->id) {
abort(404);
}
$validated = $request->validate([
'first_name' => 'required|string|max:100',
'last_name' => 'nullable|string|max:100',
'email' => 'nullable|email|max:255',
'phone' => 'nullable|string|max:50',
'title' => 'nullable|string|max:100',
'is_active' => 'boolean',
]);
// Handle checkbox - if not sent, default to false
$validated['is_active'] = $request->boolean('is_active');
$contact->update($validated);
return redirect()
->route('seller.business.crm.accounts.contacts', [$business->slug, $account->slug])
->with('success', 'Contact updated successfully.');
}
/**
* Delete a contact
*/
public function destroyContact(Request $request, Business $business, Business $account, Contact $contact)
{
// Verify contact belongs to this account
if ($contact->business_id !== $account->id) {
abort(404);
}
$contact->delete();
return redirect()
->route('seller.business.crm.accounts.contacts', [$business->slug, $account->slug])
->with('success', 'Contact deleted successfully.');
}
}

View File

@@ -0,0 +1,196 @@
<?php
namespace App\Http\Controllers\Seller\Crm;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\BusinessEmailIdentity;
use App\Models\Crm\CrmChannel;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class ChannelController extends Controller
{
/**
* List all CRM channels for a business.
*/
public function index(Business $business)
{
$channels = CrmChannel::forBusiness($business->id)
->orderBy('type')
->orderBy('name')
->get();
return view('seller.crm.channels.index', compact('business', 'channels'));
}
/**
* Show the create channel form.
*/
public function create(Business $business)
{
// Get available email identities
$emailIdentities = BusinessEmailIdentity::forBusiness($business->id)
->active()
->with('mailSettings')
->get();
return view('seller.crm.channels.create', [
'business' => $business,
'channel' => null,
'emailIdentities' => $emailIdentities,
'types' => [
CrmChannel::TYPE_EMAIL => 'Email',
CrmChannel::TYPE_SMS => 'SMS',
],
'departments' => CrmChannel::DEPARTMENTS,
]);
}
/**
* Store a new channel.
*/
public function store(Request $request, Business $business)
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:100'],
'type' => ['required', 'string', Rule::in([CrmChannel::TYPE_EMAIL, CrmChannel::TYPE_SMS])],
'department' => ['required', 'string', Rule::in(array_keys(CrmChannel::DEPARTMENTS))],
'is_active' => ['boolean'],
// Email-specific
'identity_id' => ['nullable', 'required_if:type,email', 'exists:business_email_identities,id'],
// SMS-specific
'phone_number' => ['nullable', 'required_if:type,sms', 'string', 'max:20'],
]);
// Build config based on type
$config = ['department' => $validated['department']];
$identifier = null;
if ($validated['type'] === CrmChannel::TYPE_EMAIL && ! empty($validated['identity_id'])) {
$identity = BusinessEmailIdentity::where('business_id', $business->id)
->findOrFail($validated['identity_id']);
$config['identity_id'] = $identity->id;
$config['mail_settings_id'] = $identity->mail_settings_id;
$identifier = $identity->email;
}
if ($validated['type'] === CrmChannel::TYPE_SMS && ! empty($validated['phone_number'])) {
$config['phone_number'] = $validated['phone_number'];
$identifier = $validated['phone_number'];
}
$channel = CrmChannel::create([
'business_id' => $business->id,
'type' => $validated['type'],
'name' => $validated['name'],
'department' => $validated['department'],
'identifier' => $identifier,
'config' => $config,
'is_active' => $request->boolean('is_active', true),
'can_send' => true,
'can_receive' => true,
]);
// Link the email identity to this channel
if ($validated['type'] === CrmChannel::TYPE_EMAIL && ! empty($validated['identity_id'])) {
BusinessEmailIdentity::where('id', $validated['identity_id'])
->update(['crm_channel_id' => $channel->id]);
}
return redirect()
->route('seller.business.crm.channels.index', $business)
->with('success', 'Channel created successfully.');
}
/**
* Show the edit channel form.
*/
public function edit(Business $business, CrmChannel $channel)
{
// Security: ensure channel belongs to business
if ($channel->business_id !== $business->id) {
abort(404);
}
// Get available email identities
$emailIdentities = BusinessEmailIdentity::forBusiness($business->id)
->active()
->with('mailSettings')
->get();
return view('seller.crm.channels.edit', [
'business' => $business,
'channel' => $channel,
'emailIdentities' => $emailIdentities,
'types' => [
CrmChannel::TYPE_EMAIL => 'Email',
CrmChannel::TYPE_SMS => 'SMS',
],
'departments' => CrmChannel::DEPARTMENTS,
]);
}
/**
* Update an existing channel.
*/
public function update(Request $request, Business $business, CrmChannel $channel)
{
// Security: ensure channel belongs to business
if ($channel->business_id !== $business->id) {
abort(404);
}
$validated = $request->validate([
'name' => ['required', 'string', 'max:100'],
'department' => ['required', 'string', Rule::in(array_keys(CrmChannel::DEPARTMENTS))],
'is_active' => ['boolean'],
// Email-specific
'identity_id' => ['nullable', 'exists:business_email_identities,id'],
// SMS-specific
'phone_number' => ['nullable', 'string', 'max:20'],
]);
// Build config based on type
$config = $channel->config ?? [];
$config['department'] = $validated['department'];
$identifier = $channel->identifier;
if ($channel->type === CrmChannel::TYPE_EMAIL && ! empty($validated['identity_id'])) {
$identity = BusinessEmailIdentity::where('business_id', $business->id)
->findOrFail($validated['identity_id']);
// Unlink old identity if different
$oldIdentityId = $config['identity_id'] ?? null;
if ($oldIdentityId && $oldIdentityId != $identity->id) {
BusinessEmailIdentity::where('id', $oldIdentityId)
->update(['crm_channel_id' => null]);
}
$config['identity_id'] = $identity->id;
$config['mail_settings_id'] = $identity->mail_settings_id;
$identifier = $identity->email;
// Link new identity
$identity->update(['crm_channel_id' => $channel->id]);
}
if ($channel->type === CrmChannel::TYPE_SMS && ! empty($validated['phone_number'])) {
$config['phone_number'] = $validated['phone_number'];
$identifier = $validated['phone_number'];
}
$channel->update([
'name' => $validated['name'],
'department' => $validated['department'],
'identifier' => $identifier,
'config' => $config,
'is_active' => $request->boolean('is_active', true),
]);
return redirect()
->route('seller.business.crm.channels.index', $business)
->with('success', 'Channel updated successfully.');
}
}

View File

@@ -0,0 +1,252 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller\Crm;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Contact;
use Illuminate\Http\Request;
class ContactController extends Controller
{
/**
* Display all CRM contacts (contacts from buyer businesses).
*/
public function index(Request $request, Business $business)
{
$query = Contact::query()
->whereHas('business', function ($q) {
$q->where('type', 'buyer');
})
->with(['business', 'location']);
// Search filter
if ($request->filled('q')) {
$search = $request->q;
$query->where(function ($q) use ($search) {
$q->where('first_name', 'ILIKE', "%{$search}%")
->orWhere('last_name', 'ILIKE', "%{$search}%")
->orWhere('email', 'ILIKE', "%{$search}%")
->orWhere('phone', 'ILIKE', "%{$search}%")
->orWhere('position', 'ILIKE', "%{$search}%")
->orWhereHas('business', function ($q) use ($search) {
$q->where('name', 'ILIKE', "%{$search}%")
->orWhere('dba_name', 'ILIKE', "%{$search}%");
});
});
}
// Account filter
if ($request->filled('account')) {
$query->where('business_id', $request->account);
}
// Contact type filter
if ($request->filled('type')) {
$query->where('contact_type', $request->type);
}
// Active filter - default to active
if ($request->filled('status')) {
if ($request->status === 'inactive') {
$query->where('is_active', false);
} elseif ($request->status === 'all') {
// Show all
} else {
$query->where('is_active', true);
}
} else {
$query->where('is_active', true);
}
$contacts = $query
->orderBy('last_name')
->orderBy('first_name')
->paginate(25)
->withQueryString();
// Return JSON for AJAX/API requests (live search)
if ($request->wantsJson()) {
return response()->json([
'data' => $contacts->map(fn ($c) => [
'hashid' => $c->hashid,
'name' => $c->getFullName(),
'email' => $c->email,
'account' => $c->business?->name,
])->values()->toArray(),
]);
}
// Get accounts for filter dropdown
$accounts = Business::where('type', 'buyer')
->where('status', 'approved')
->orderBy('name')
->get(['id', 'name', 'dba_name']);
return view('seller.crm.contacts.index', compact('business', 'contacts', 'accounts'));
}
/**
* Show the form for creating a new contact.
*/
public function create(Request $request, Business $business)
{
$accounts = Business::where('type', 'buyer')
->where('status', 'approved')
->orderBy('name')
->get(['id', 'name', 'dba_name']);
$selectedAccount = $request->filled('account')
? Business::find($request->account)
: null;
return view('seller.crm.contacts.create', compact('business', 'accounts', 'selectedAccount'));
}
/**
* Store a newly created contact.
*/
public function store(Request $request, Business $business)
{
$validated = $request->validate([
'business_id' => 'required|exists:businesses,id',
'first_name' => 'required|string|max:255',
'last_name' => 'required|string|max:255',
'email' => 'required|email|max:255',
'phone' => 'nullable|string|max:50',
'mobile' => 'nullable|string|max:50',
'position' => 'nullable|string|max:255',
'contact_type' => 'nullable|string|in:'.implode(',', array_keys(Contact::CONTACT_TYPES)),
'preferred_contact_method' => 'nullable|string|in:'.implode(',', array_keys(Contact::COMMUNICATION_METHODS)),
'is_primary' => 'nullable|boolean',
'notes' => 'nullable|string|max:1000',
]);
// Verify the target business is a buyer
$targetBusiness = Business::findOrFail($validated['business_id']);
if ($targetBusiness->type !== 'buyer') {
return redirect()->back()->with('error', 'Contacts can only be added to customer accounts.');
}
// If setting as primary, remove primary from other contacts
if ($request->boolean('is_primary')) {
Contact::where('business_id', $validated['business_id'])->update(['is_primary' => false]);
}
$contact = Contact::create([
'business_id' => $validated['business_id'],
'first_name' => $validated['first_name'],
'last_name' => $validated['last_name'],
'email' => $validated['email'],
'phone' => $validated['phone'] ?? null,
'mobile' => $validated['mobile'] ?? null,
'position' => $validated['position'] ?? null,
'contact_type' => $validated['contact_type'] ?? 'general',
'preferred_contact_method' => $validated['preferred_contact_method'] ?? 'email',
'is_primary' => $request->boolean('is_primary', false),
'is_active' => true,
'notes' => $validated['notes'] ?? null,
'created_by' => auth()->id(),
]);
return redirect()
->route('seller.business.crm.contacts.index', $business)
->with('success', "Contact '{$contact->getFullName()}' created successfully.");
}
/**
* Show the form for editing a contact.
*/
public function edit(Business $business, Contact $contact)
{
// Verify contact belongs to a buyer business
if ($contact->business->type !== 'buyer') {
abort(404);
}
$accounts = Business::where('type', 'buyer')
->where('status', 'approved')
->orderBy('name')
->get(['id', 'name', 'dba_name']);
return view('seller.crm.contacts.edit', compact('business', 'contact', 'accounts'));
}
/**
* Update a contact.
*/
public function update(Request $request, Business $business, Contact $contact)
{
// Verify contact belongs to a buyer business
if ($contact->business->type !== 'buyer') {
abort(404);
}
$validated = $request->validate([
'business_id' => 'required|exists:businesses,id',
'first_name' => 'required|string|max:255',
'last_name' => 'required|string|max:255',
'email' => 'required|email|max:255',
'phone' => 'nullable|string|max:50',
'mobile' => 'nullable|string|max:50',
'position' => 'nullable|string|max:255',
'contact_type' => 'nullable|string|in:'.implode(',', array_keys(Contact::CONTACT_TYPES)),
'preferred_contact_method' => 'nullable|string|in:'.implode(',', array_keys(Contact::COMMUNICATION_METHODS)),
'is_primary' => 'nullable|boolean',
'notes' => 'nullable|string|max:1000',
]);
// Verify the target business is a buyer
$targetBusiness = Business::findOrFail($validated['business_id']);
if ($targetBusiness->type !== 'buyer') {
return redirect()->back()->with('error', 'Contacts can only belong to customer accounts.');
}
// If setting as primary, remove primary from other contacts
if ($request->boolean('is_primary') && ! $contact->is_primary) {
Contact::where('business_id', $validated['business_id'])
->where('id', '!=', $contact->id)
->update(['is_primary' => false]);
}
$contact->update([
'business_id' => $validated['business_id'],
'first_name' => $validated['first_name'],
'last_name' => $validated['last_name'],
'email' => $validated['email'],
'phone' => $validated['phone'] ?? null,
'mobile' => $validated['mobile'] ?? null,
'position' => $validated['position'] ?? null,
'contact_type' => $validated['contact_type'] ?? 'general',
'preferred_contact_method' => $validated['preferred_contact_method'] ?? 'email',
'is_primary' => $request->boolean('is_primary', false),
'notes' => $validated['notes'] ?? null,
'updated_by' => auth()->id(),
]);
return redirect()
->route('seller.business.crm.contacts.index', $business)
->with('success', "Contact '{$contact->getFullName()}' updated successfully.");
}
/**
* Archive/delete a contact.
*/
public function destroy(Business $business, Contact $contact)
{
// Verify contact belongs to a buyer business
if ($contact->business->type !== 'buyer') {
abort(404);
}
$name = $contact->getFullName();
$contact->archive('Deleted via CRM', auth()->user());
return redirect()
->route('seller.business.crm.contacts.index', $business)
->with('success', "Contact '{$name}' has been archived.");
}
}

View File

@@ -5,8 +5,13 @@ namespace App\Http\Controllers\Seller\Crm;
use App\Http\Controllers\Controller;
use App\Jobs\Crm\SyncCalendarJob;
use App\Models\Business;
use App\Models\CalendarEvent;
use App\Models\Contact;
use App\Models\Crm\CrmCalendarConnection;
use App\Models\Crm\CrmMeetingBooking;
use App\Models\Crm\CrmSyncedEvent;
use App\Models\Crm\CrmTask;
use App\Models\User;
use App\Services\Crm\CrmCalendarService;
use Illuminate\Http\Request;
@@ -17,7 +22,7 @@ class CrmCalendarController extends Controller
) {}
/**
* Calendar view
* Calendar view - unified activity calendar
*/
public function index(Request $request, Business $business)
{
@@ -28,52 +33,402 @@ class CrmCalendarController extends Controller
->where('user_id', $user->id)
->get();
// Get events for calendar view
$startDate = $request->input('start', now()->startOfMonth());
$endDate = $request->input('end', now()->endOfMonth());
// Get team members for assignment dropdown
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
->select('id', 'first_name', 'last_name', 'email')
->get();
$events = CrmSyncedEvent::whereIn('calendar_connection_id', $connections->pluck('id'))
->whereBetween('start_at', [$startDate, $endDate])
// Get contacts for event creation
$customerBusinessIds = \App\Models\Order::whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
->pluck('business_id')
->unique();
$contacts = Contact::whereIn('business_id', $customerBusinessIds)
->with('business:id,name')
->orderBy('first_name')
->limit(200)
->get();
// Get event types and colors for legend/forms
$eventTypes = CalendarEvent::TYPES;
$eventColors = CalendarEvent::TYPE_COLORS;
// Pass $business to view for route generation (Premium CRM uses seller.business.crm.* routes)
return view('seller.crm.calendar.index', compact(
'business',
'connections',
'teamMembers',
'contacts',
'eventTypes',
'eventColors'
));
}
/**
* API: Get all events for date range (unified: internal + synced + bookings + tasks)
*/
public function events(Request $request, Business $business)
{
$user = $request->user();
$validated = $request->validate([
'start' => 'required|date',
'end' => 'required|date|after:start',
]);
$startDate = $validated['start'];
$endDate = $validated['end'];
$allEvents = collect();
// 1. Internal CalendarEvents
$internalEvents = CalendarEvent::forSellerBusiness($business->id)
->inDateRange($startDate, $endDate)
->with(['contact:id,first_name,last_name', 'assignee:id,first_name,last_name'])
->get()
->map(fn ($e) => [
'id' => $e->id,
'id' => 'event_'.$e->id,
'title' => $e->title,
'start' => $e->start_at->toIso8601String(),
'end' => $e->end_at?->toIso8601String(),
'allDay' => $e->all_day,
'color' => $e->connection->provider === 'google' ? '#4285f4' : '#0078d4',
'color' => $e->getColor(),
'classNames' => ['calendar-event-internal', 'event-type-'.$e->type],
'extendedProps' => [
'source' => 'internal',
'event_id' => $e->id,
'type' => $e->type,
'type_label' => $e->getTypeLabel(),
'status' => $e->status,
'location' => $e->location,
'description' => $e->description,
'attendees' => $e->attendees,
'contact_id' => $e->contact_id,
'contact_name' => $e->contact ? $e->contact->first_name.' '.$e->contact->last_name : null,
'assigned_to' => $e->assigned_to,
'assignee_name' => $e->assignee ? $e->assignee->first_name.' '.$e->assignee->last_name : null,
'editable' => true,
],
]);
$allEvents = $allEvents->merge($internalEvents);
// Get meeting bookings
$bookings = \App\Models\Crm\CrmMeetingBooking::whereHas('meetingLink', function ($q) use ($business, $user) {
// 2. Synced external events (Google/Outlook)
$connections = CrmCalendarConnection::where('business_id', $business->id)
->where('user_id', $user->id)
->where('sync_enabled', true)
->pluck('id');
if ($connections->isNotEmpty()) {
$syncedEvents = CrmSyncedEvent::whereIn('calendar_connection_id', $connections)
->whereBetween('start_at', [$startDate, $endDate])
->with('connection:id,provider')
->get()
->map(fn ($e) => [
'id' => 'synced_'.$e->id,
'title' => $e->title,
'start' => $e->start_at->toIso8601String(),
'end' => $e->end_at?->toIso8601String(),
'allDay' => $e->all_day,
'color' => $e->connection->provider === 'google' ? '#4285f4' : '#0078d4',
'classNames' => ['calendar-event-synced', 'provider-'.$e->connection->provider],
'extendedProps' => [
'source' => 'synced',
'provider' => $e->connection->provider,
'location' => $e->location,
'description' => $e->description,
'attendees' => $e->attendees,
'external_link' => $e->external_link,
'editable' => false,
],
]);
$allEvents = $allEvents->merge($syncedEvents);
}
// 3. Meeting bookings
$bookings = CrmMeetingBooking::whereHas('meetingLink', function ($q) use ($business, $user) {
$q->where('business_id', $business->id)
->where('user_id', $user->id);
})
->whereBetween('start_at', [$startDate, $endDate])
->with(['meetingLink', 'contact'])
->where('status', '!=', 'cancelled')
->with(['meetingLink:id,name', 'contact:id,first_name,last_name'])
->get()
->map(fn ($b) => [
'id' => 'booking_'.$b->id,
'title' => $b->meetingLink->name.' - '.$b->booker_name,
'title' => ($b->meetingLink->name ?? 'Meeting').' - '.$b->booker_name,
'start' => $b->start_at->toIso8601String(),
'end' => $b->end_at->toIso8601String(),
'color' => '#10b981',
'classNames' => ['calendar-event-booking'],
'extendedProps' => [
'type' => 'booking',
'contact_id' => $b->contact_id,
'source' => 'booking',
'booking_id' => $b->id,
'status' => $b->status,
'booker_name' => $b->booker_name,
'booker_email' => $b->booker_email,
'contact_id' => $b->contact_id,
'contact_name' => $b->contact ? $b->contact->first_name.' '.$b->contact->last_name : null,
'location' => $b->location,
'editable' => false,
],
]);
$allEvents = $allEvents->merge($bookings);
$allEvents = $events->merge($bookings);
// 4. CRM Tasks with due dates (shown as all-day markers)
$tasks = CrmTask::forSellerBusiness($business->id)
->incomplete()
->whereNotNull('due_at')
->whereBetween('due_at', [$startDate, $endDate])
->with(['contact:id,first_name,last_name', 'assignee:id,first_name,last_name'])
->get()
->map(fn ($t) => [
'id' => 'task_'.$t->id,
'title' => '📋 '.$t->title,
'start' => $t->due_at->toDateString(),
'allDay' => true,
'color' => $t->isOverdue() ? '#EF4444' : '#F59E0B',
'classNames' => ['calendar-event-task', $t->isOverdue() ? 'task-overdue' : ''],
'extendedProps' => [
'source' => 'task',
'task_id' => $t->id,
'type' => $t->type,
'priority' => $t->priority,
'contact_id' => $t->contact_id,
'contact_name' => $t->contact ? $t->contact->first_name.' '.$t->contact->last_name : null,
'assigned_to' => $t->assigned_to,
'assignee_name' => $t->assignee ? $t->assignee->first_name.' '.$t->assignee->last_name : null,
'is_overdue' => $t->isOverdue(),
'editable' => false,
],
]);
$allEvents = $allEvents->merge($tasks);
// Pass $business to view for route generation (Premium CRM uses seller.business.crm.* routes)
return view('seller.crm.calendar.index', compact('business', 'connections', 'allEvents'));
return response()->json($allEvents->values());
}
/**
* Store a new calendar event
*/
public function store(Request $request, Business $business)
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'description' => 'nullable|string|max:5000',
'location' => 'nullable|string|max:255',
'start_at' => 'required|date',
'end_at' => 'nullable|date|after:start_at',
'all_day' => 'boolean',
'type' => 'required|string|in:'.implode(',', array_keys(CalendarEvent::TYPES)),
'contact_id' => 'nullable|exists:contacts,id',
'assigned_to' => 'nullable|exists:users,id',
'reminder_minutes' => 'nullable|integer|min:0',
]);
// Security: verify contact belongs to a customer business
if (! empty($validated['contact_id'])) {
$customerBusinessIds = \App\Models\Order::whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
->pluck('business_id')
->unique();
Contact::whereIn('business_id', $customerBusinessIds)
->findOrFail($validated['contact_id']);
}
// Security: verify assignee belongs to business
if (! empty($validated['assigned_to'])) {
User::where('id', $validated['assigned_to'])
->whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
->firstOrFail();
}
$event = CalendarEvent::create([
'seller_business_id' => $business->id,
'created_by' => $request->user()->id,
'assigned_to' => $validated['assigned_to'] ?? $request->user()->id,
'title' => $validated['title'],
'description' => $validated['description'] ?? null,
'location' => $validated['location'] ?? null,
'start_at' => $validated['start_at'],
'end_at' => $validated['end_at'] ?? null,
'all_day' => $validated['all_day'] ?? false,
'type' => $validated['type'],
'status' => 'scheduled',
'contact_id' => $validated['contact_id'] ?? null,
'reminder_at' => isset($validated['reminder_minutes']) && $validated['reminder_minutes'] > 0
? now()->parse($validated['start_at'])->subMinutes($validated['reminder_minutes'])
: null,
]);
if ($request->wantsJson()) {
return response()->json([
'success' => true,
'event' => $event->load(['contact:id,first_name,last_name', 'assignee:id,first_name,last_name']),
]);
}
return back()->with('success', 'Event created successfully.');
}
/**
* Update a calendar event
*/
public function update(Request $request, Business $business, CalendarEvent $event)
{
if ($event->seller_business_id !== $business->id) {
abort(404);
}
$validated = $request->validate([
'title' => 'sometimes|required|string|max:255',
'description' => 'nullable|string|max:5000',
'location' => 'nullable|string|max:255',
'start_at' => 'sometimes|required|date',
'end_at' => 'nullable|date|after:start_at',
'all_day' => 'boolean',
'type' => 'sometimes|required|string|in:'.implode(',', array_keys(CalendarEvent::TYPES)),
'status' => 'sometimes|required|string|in:scheduled,completed,cancelled',
'contact_id' => 'nullable|exists:contacts,id',
'assigned_to' => 'nullable|exists:users,id',
'reminder_minutes' => 'nullable|integer|min:0',
]);
// Security checks for contact and assignee
if (isset($validated['contact_id']) && $validated['contact_id']) {
$customerBusinessIds = \App\Models\Order::whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
->pluck('business_id')
->unique();
Contact::whereIn('business_id', $customerBusinessIds)
->findOrFail($validated['contact_id']);
}
if (isset($validated['assigned_to']) && $validated['assigned_to']) {
User::where('id', $validated['assigned_to'])
->whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
->firstOrFail();
}
// Handle reminder
if (isset($validated['reminder_minutes'])) {
$validated['reminder_at'] = $validated['reminder_minutes'] > 0
? now()->parse($validated['start_at'] ?? $event->start_at)->subMinutes($validated['reminder_minutes'])
: null;
$validated['reminder_sent'] = false;
unset($validated['reminder_minutes']);
}
$event->update($validated);
if ($request->wantsJson()) {
return response()->json([
'success' => true,
'event' => $event->fresh()->load(['contact:id,first_name,last_name', 'assignee:id,first_name,last_name']),
]);
}
return back()->with('success', 'Event updated successfully.');
}
/**
* Quick reschedule via drag-and-drop
*/
public function reschedule(Request $request, Business $business, CalendarEvent $event)
{
if ($event->seller_business_id !== $business->id) {
return response()->json(['error' => 'Unauthorized'], 403);
}
$validated = $request->validate([
'start_at' => 'required|date',
'end_at' => 'nullable|date|after:start_at',
'all_day' => 'boolean',
]);
$event->reschedule(
$validated['start_at'],
$validated['end_at'] ?? null,
$request->user()
);
if (isset($validated['all_day'])) {
$event->update(['all_day' => $validated['all_day']]);
}
return response()->json([
'success' => true,
'event' => $event->fresh(),
]);
}
/**
* Mark event as complete
*/
public function complete(Request $request, Business $business, CalendarEvent $event)
{
if ($event->seller_business_id !== $business->id) {
abort(404);
}
$event->markComplete($request->user());
if ($request->wantsJson()) {
return response()->json(['success' => true, 'event' => $event->fresh()]);
}
return back()->with('success', 'Event marked as complete.');
}
/**
* Cancel an event
*/
public function cancel(Request $request, Business $business, CalendarEvent $event)
{
if ($event->seller_business_id !== $business->id) {
abort(404);
}
$event->cancel($request->user());
if ($request->wantsJson()) {
return response()->json(['success' => true, 'event' => $event->fresh()]);
}
return back()->with('success', 'Event cancelled.');
}
/**
* Delete an event
*/
public function destroy(Request $request, Business $business, CalendarEvent $event)
{
if ($event->seller_business_id !== $business->id) {
abort(404);
}
$event->delete();
if ($request->wantsJson()) {
return response()->json(['success' => true]);
}
return back()->with('success', 'Event deleted.');
}
/**
* Get single event details (for modal)
*/
public function show(Request $request, Business $business, CalendarEvent $event)
{
if ($event->seller_business_id !== $business->id) {
abort(404);
}
$event->load([
'contact:id,first_name,last_name,email,phone',
'business:id,name',
'assignee:id,first_name,last_name,email',
'creator:id,first_name,last_name',
]);
return response()->json($event);
}
/**
@@ -104,7 +459,7 @@ class CrmCalendarController extends Controller
$params = http_build_query([
'client_id' => config('services.google.client_id'),
'redirect_uri' => route('seller.crm.calendar.callback'),
'redirect_uri' => route('seller.business.crm.calendar.callback', $business),
'response_type' => 'code',
'scope' => 'https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/calendar.events',
'access_type' => 'offline',
@@ -128,7 +483,7 @@ class CrmCalendarController extends Controller
$params = http_build_query([
'client_id' => config('services.microsoft.client_id'),
'redirect_uri' => route('seller.crm.calendar.callback'),
'redirect_uri' => route('seller.business.crm.calendar.callback', $business),
'response_type' => 'code',
'scope' => 'offline_access Calendars.ReadWrite',
'state' => $state,
@@ -140,17 +495,17 @@ class CrmCalendarController extends Controller
/**
* OAuth callback
*/
public function callback(Request $request)
public function callback(Request $request, Business $business)
{
if ($request->has('error')) {
return redirect()->route('seller.crm.calendar.connections')
return redirect()->route('seller.business.crm.calendar.connections', $business)
->withErrors(['error' => 'Authorization failed: '.$request->input('error_description')]);
}
try {
$state = decrypt($request->input('state'));
} catch (\Exception $e) {
return redirect()->route('seller.crm.calendar.connections')
return redirect()->route('seller.business.crm.calendar.connections', $business)
->withErrors(['error' => 'Invalid state parameter.']);
}
@@ -161,7 +516,7 @@ class CrmCalendarController extends Controller
$tokens = $this->calendarService->exchangeCodeForTokens($provider, $code);
if (! $tokens) {
return redirect()->route('seller.crm.calendar.connections')
return redirect()->route('seller.business.crm.calendar.connections', $business)
->withErrors(['error' => 'Failed to obtain access token.']);
}
@@ -189,7 +544,7 @@ class CrmCalendarController extends Controller
// Queue initial sync
SyncCalendarJob::dispatch($state['user_id'], $provider);
return redirect()->route('seller.crm.calendar.connections')
return redirect()->route('seller.business.crm.calendar.connections', $business)
->with('success', ucfirst($provider).' Calendar connected successfully.');
}
@@ -238,34 +593,4 @@ class CrmCalendarController extends Controller
return back()->with('success', 'Calendar sync started. Events will appear shortly.');
}
/**
* API: Get events for date range (for calendar JS)
*/
public function events(Request $request, Business $business)
{
$user = $request->user();
$validated = $request->validate([
'start' => 'required|date',
'end' => 'required|date|after:start',
]);
$connections = CrmCalendarConnection::where('business_id', $business->id)
->where('user_id', $user->id)
->pluck('id');
$events = CrmSyncedEvent::whereIn('calendar_connection_id', $connections)
->whereBetween('start_at', [$validated['start'], $validated['end']])
->get()
->map(fn ($e) => [
'id' => $e->id,
'title' => $e->title,
'start' => $e->start_at->toIso8601String(),
'end' => $e->end_at?->toIso8601String(),
'allDay' => $e->all_day,
]);
return response()->json($events);
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Seller\Crm;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Crm\CrmDeal;
use App\Models\Crm\CrmPipeline;
use App\Models\Crm\CrmRepMetric;
use App\Models\Crm\CrmSlaTimer;
use App\Models\Crm\CrmThread;
@@ -43,13 +44,26 @@ class CrmDashboardController extends Controller
*/
public function sales(Request $request, Business $business)
{
// Get the default pipeline for stage name mapping
$defaultPipeline = CrmPipeline::where('business_id', $business->id)
->where('is_default', true)
->first();
// Pipeline summary
$stageMap = collect($defaultPipeline?->stages ?? [])->mapWithKeys(function ($stage, $index) {
return [$index => $stage['name'] ?? "Stage {$index}"];
})->all();
// Pipeline summary - group by stage_id (index into pipeline stages JSON array)
$pipelineSummary = CrmDeal::forBusiness($business->id)
->open()
->selectRaw('stage, count(*) as count, sum(value) as total_value, sum(weighted_value) as weighted_value')
->groupBy('stage')
->get();
->selectRaw('stage_id, count(*) as count, sum(value) as total_value, sum(weighted_value) as weighted_value')
->groupBy('stage_id')
->get()
->map(function ($item) use ($stageMap) {
$item->stage_name = $stageMap[$item->stage_id] ?? "Stage {$item->stage_id}";
return $item;
});
// Won/Lost this month
$monthlyStats = [

View File

@@ -86,7 +86,7 @@ class CrmSettingsController extends Controller
'is_default' => $validated['is_default'] ?? false,
]);
return redirect()->route('seller.crm.settings.channels')
return redirect()->route('seller.business.crm.settings.channels', $business)
->with('success', 'Channel created successfully.');
}
@@ -132,7 +132,7 @@ class CrmSettingsController extends Controller
$channel->update($validated);
return redirect()->route('seller.crm.settings.channels')
return redirect()->route('seller.business.crm.settings.channels', $business)
->with('success', 'Channel updated.');
}
@@ -206,7 +206,7 @@ class CrmSettingsController extends Controller
'is_default' => $validated['is_default'] ?? false,
]);
return redirect()->route('seller.crm.settings.pipelines')
return redirect()->route('seller.business.crm.settings.pipelines', $business)
->with('success', 'Pipeline created.');
}
@@ -253,7 +253,7 @@ class CrmSettingsController extends Controller
$pipeline->update($validated);
return redirect()->route('seller.crm.settings.pipelines')
return redirect()->route('seller.business.crm.settings.pipelines', $business)
->with('success', 'Pipeline updated.');
}
@@ -330,7 +330,7 @@ class CrmSettingsController extends Controller
'is_active' => $validated['is_active'] ?? true,
]);
return redirect()->route('seller.crm.settings.sla')
return redirect()->route('seller.business.crm.settings.sla', $business)
->with('success', 'SLA policy created.');
}
@@ -371,7 +371,7 @@ class CrmSettingsController extends Controller
$policy->update($validated);
return redirect()->route('seller.crm.settings.sla')
return redirect()->route('seller.business.crm.settings.sla', $business)
->with('success', 'SLA policy updated.');
}
@@ -519,7 +519,7 @@ class CrmSettingsController extends Controller
'is_active' => true,
]);
return redirect()->route('seller.crm.settings.templates')
return redirect()->route('seller.business.crm.settings.templates', $business)
->with('success', 'Template created.');
}
@@ -560,7 +560,7 @@ class CrmSettingsController extends Controller
$template->update($validated);
return redirect()->route('seller.crm.settings.templates')
return redirect()->route('seller.business.crm.settings.templates', $business)
->with('success', 'Template updated.');
}

View File

@@ -34,7 +34,7 @@ class DealController extends Controller
// Build base query for deals
$dealsQuery = CrmDeal::forBusiness($business->id)
->where('pipeline_id', $pipeline->id)
->with(['contact:id,first_name,last_name,email', 'account:id,name', 'owner:id,name,email']);
->with(['contact:id,first_name,last_name,email', 'account:id,name', 'owner:id,first_name,last_name,email']);
// Filters
if ($request->filled('owner_id')) {
@@ -73,7 +73,7 @@ class DealController extends Controller
// Get team members (limited fields)
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
->select('id', 'name', 'email')
->select('id', 'first_name', 'last_name', 'email')
->get();
// Calculate stats with single efficient query using selectRaw
@@ -110,7 +110,7 @@ class DealController extends Controller
// Limit contacts for dropdown - most recent 100
$contacts = Contact::where('business_id', $business->id)
->select('id', 'first_name', 'last_name', 'email', 'company_name')
->select('id', 'first_name', 'last_name', 'email')
->orderByDesc('updated_at')
->limit(100)
->get();
@@ -125,7 +125,7 @@ class DealController extends Controller
->get();
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
->select('id', 'name', 'email')
->select('id', 'first_name', 'last_name', 'email')
->get();
$brands = Brand::where('business_id', $business->id)
@@ -200,7 +200,7 @@ class DealController extends Controller
'status' => CrmDeal::STATUS_OPEN,
]);
return redirect()->route('seller.crm.deals.show', $deal)
return redirect()->route('seller.business.crm.deals.show', [$business, $deal])
->with('success', 'Deal created successfully.');
}
@@ -373,7 +373,7 @@ class DealController extends Controller
$deal->delete();
return redirect()->route('seller.crm.deals.index')
return redirect()->route('seller.business.crm.deals.index', $business)
->with('success', 'Deal deleted.');
}
}

View File

@@ -32,8 +32,8 @@ class InvoiceController extends Controller
if ($request->filled('search')) {
$query->where(function ($q) use ($request) {
$q->where('invoice_number', 'like', "%{$request->search}%")
->orWhere('title', 'like', "%{$request->search}%");
$q->where('invoice_number', 'ILIKE', "%{$request->search}%")
->orWhere('title', 'ILIKE', "%{$request->search}%");
});
}
@@ -163,7 +163,7 @@ class InvoiceController extends Controller
$invoice->calculateTotals();
return redirect()->route('seller.crm.invoices.show', $invoice)
return redirect()->route('seller.business.crm.invoices.show', [$business, $invoice])
->with('success', 'Invoice created successfully.');
}
@@ -278,7 +278,7 @@ class InvoiceController extends Controller
$invoice->delete();
return redirect()->route('seller.crm.invoices.index')
return redirect()->route('seller.business.crm.invoices.index', $business)
->with('success', 'Invoice deleted.');
}
}

View File

@@ -0,0 +1,171 @@
<?php
namespace App\Http\Controllers\Seller\Crm;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Crm\CrmLead;
use Illuminate\Http\Request;
class LeadController extends Controller
{
/**
* Display leads listing
*/
public function index(Request $request, Business $business)
{
$query = CrmLead::forSeller($business)
->with('assignee')
->notConverted();
// Search filter
if ($request->filled('q')) {
$search = $request->q;
$query->where(function ($q) use ($search) {
$q->where('company_name', 'ILIKE', "%{$search}%")
->orWhere('contact_name', 'ILIKE', "%{$search}%")
->orWhere('contact_email', 'ILIKE', "%{$search}%");
});
}
// Status filter
if ($request->filled('status') && $request->status !== 'all') {
$query->where('status', $request->status);
}
$leads = $query->latest()->paginate(25);
// Return JSON for AJAX/API requests (live search)
if ($request->wantsJson()) {
return response()->json([
'data' => $leads->map(fn ($l) => [
'hashid' => $l->hashid,
'name' => $l->company_name,
'contact' => $l->contact_name,
'email' => $l->contact_email,
'status' => $l->status,
])->values()->toArray(),
]);
}
return view('seller.crm.leads.index', compact('business', 'leads'));
}
/**
* Show create lead form
*/
public function create(Request $request, Business $business)
{
return view('seller.crm.leads.create', compact('business'));
}
/**
* Store a new lead
*/
public function store(Request $request, Business $business)
{
$validated = $request->validate([
'company_name' => 'required|string|max:255',
'dba_name' => 'nullable|string|max:255',
'license_number' => 'nullable|string|max:100',
'contact_name' => 'required|string|max:255',
'contact_email' => 'nullable|email|max:255',
'contact_phone' => 'nullable|string|max:50',
'contact_title' => 'nullable|string|max:100',
'city' => 'nullable|string|max:100',
'state' => 'nullable|string|max:50',
'address' => 'nullable|string|max:255',
'zip_code' => 'nullable|string|max:20',
'source' => 'nullable|string|in:'.implode(',', array_keys(CrmLead::SOURCES)),
'notes' => 'nullable|string|max:5000',
]);
$validated['seller_business_id'] = $business->id;
$validated['status'] = 'new';
$lead = CrmLead::create($validated);
return redirect()
->route('seller.business.crm.leads.show', [$business->slug, $lead->hashid])
->with('success', 'Lead created successfully.');
}
/**
* Show lead details
*/
public function show(Request $request, Business $business, CrmLead $lead)
{
// Ensure lead belongs to this seller
if ($lead->seller_business_id !== $business->id) {
abort(404);
}
$lead->load('assignee');
return view('seller.crm.leads.show', compact('business', 'lead'));
}
/**
* Show edit lead form
*/
public function edit(Request $request, Business $business, CrmLead $lead)
{
// Ensure lead belongs to this seller
if ($lead->seller_business_id !== $business->id) {
abort(404);
}
return view('seller.crm.leads.edit', compact('business', 'lead'));
}
/**
* Update a lead
*/
public function update(Request $request, Business $business, CrmLead $lead)
{
// Ensure lead belongs to this seller
if ($lead->seller_business_id !== $business->id) {
abort(404);
}
$validated = $request->validate([
'company_name' => 'required|string|max:255',
'dba_name' => 'nullable|string|max:255',
'license_number' => 'nullable|string|max:100',
'contact_name' => 'required|string|max:255',
'contact_email' => 'nullable|email|max:255',
'contact_phone' => 'nullable|string|max:50',
'contact_title' => 'nullable|string|max:100',
'city' => 'nullable|string|max:100',
'state' => 'nullable|string|max:50',
'address' => 'nullable|string|max:255',
'zip_code' => 'nullable|string|max:20',
'source' => 'nullable|string|in:'.implode(',', array_keys(CrmLead::SOURCES)),
'status' => 'nullable|string|in:'.implode(',', array_keys(CrmLead::STATUSES)),
'notes' => 'nullable|string|max:5000',
]);
$lead->update($validated);
return redirect()
->route('seller.business.crm.leads.show', [$business->slug, $lead->hashid])
->with('success', 'Lead updated successfully.');
}
/**
* Delete a lead
*/
public function destroy(Request $request, Business $business, CrmLead $lead)
{
// Ensure lead belongs to this seller
if ($lead->seller_business_id !== $business->id) {
abort(404);
}
$lead->delete();
return redirect()
->route('seller.business.crm.leads.index', $business->slug)
->with('success', 'Lead deleted.');
}
}

View File

@@ -81,7 +81,7 @@ class MeetingLinkController extends Controller
'is_active' => $validated['is_active'] ?? true,
]);
return redirect()->route('seller.crm.meetings.links.show', $meetingLink)
return redirect()->route('seller.business.crm.meetings.links.show', [$business, $meetingLink])
->with('success', 'Meeting link created. Share the booking URL with contacts.');
}
@@ -136,7 +136,7 @@ class MeetingLinkController extends Controller
$meetingLink->update($validated);
return redirect()->route('seller.crm.meetings.links.show', $meetingLink)
return redirect()->route('seller.business.crm.meetings.links.show', [$business, $meetingLink])
->with('success', 'Meeting link updated.');
}
@@ -165,7 +165,7 @@ class MeetingLinkController extends Controller
$meetingLink->delete();
return redirect()->route('seller.crm.meetings.links.index')
return redirect()->route('seller.business.crm.meetings.links.index', $business)
->with('success', 'Meeting link deleted.');
}

View File

@@ -3,14 +3,21 @@
namespace App\Http\Controllers\Seller\Crm;
use App\Http\Controllers\Controller;
use App\Mail\QuoteMail;
use App\Models\Activity;
use App\Models\Business;
use App\Models\Contact;
use App\Models\Crm\CrmDeal;
use App\Models\Crm\CrmQuote;
use App\Models\Crm\CrmQuoteItem;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Product;
use App\Services\Accounting\ArService;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
class QuoteController extends Controller
{
@@ -37,6 +44,19 @@ class QuoteController extends Controller
$quotes = $query->orderByDesc('created_at')->paginate(25);
// Return JSON for AJAX/API requests (live search)
if ($request->wantsJson()) {
return response()->json([
'data' => $quotes->map(fn ($q) => [
'id' => $q->id,
'name' => $q->quote_number.' - '.($q->title ?? 'Untitled'),
'contact' => $q->contact?->name ?? '-',
'status' => $q->status,
'total' => '$'.number_format($q->total, 2),
])->values()->toArray(),
]);
}
return view('seller.crm.quotes.index', compact('quotes', 'business'));
}
@@ -45,21 +65,26 @@ class QuoteController extends Controller
*/
public function create(Request $request, Business $business)
{
$contacts = Contact::where('business_id', $business->id)->get();
$accounts = Business::whereHas('ordersAsCustomer', function ($q) use ($business) {
$q->whereHas('items.product.brand', fn ($b) => $b->where('business_id', $business->id));
})->get();
$deals = CrmDeal::forBusiness($business->id)->open()->get();
$products = Product::whereHas('brand', fn ($q) => $q->where('business_id', $business->id))
->where('is_active', true)
// Get all approved buyer businesses as potential customers
// Contacts are loaded dynamically via /search/contacts?customer_id={account_id}
// Include locations for delivery address selection
// Note: We don't filter by whereHas('contacts') because newly created customers
// may not have contacts yet - contacts can be added after selecting the account
$accounts = Business::where('type', 'buyer')
->where('status', 'approved')
->with('locations:id,business_id,name,is_primary')
->orderBy('name')
->select(['id', 'name', 'slug'])
->get();
$deals = CrmDeal::forBusiness($business->id)->open()->get();
// Products are loaded via AJAX search (/search/products) for better performance
// Pre-fill from deal if provided
$deal = $request->filled('deal_id')
? CrmDeal::forBusiness($business->id)->find($request->deal_id)
: null;
return view('seller.crm.quotes.create', compact('contacts', 'accounts', 'deals', 'products', 'deal', 'business'));
return view('seller.crm.quotes.create', compact('accounts', 'deals', 'deal', 'business'));
}
/**
@@ -87,10 +112,13 @@ class QuoteController extends Controller
'items.*.discount_percent' => 'nullable|numeric|min:0|max:100',
]);
// SECURITY: Verify contact belongs to business
Contact::where('id', $validated['contact_id'])
->where('business_id', $business->id)
->firstOrFail();
// SECURITY: Verify contact belongs to the selected account (customer business)
// Contacts are associated with buyer businesses, not the seller
if (! empty($validated['account_id'])) {
Contact::where('id', $validated['contact_id'])
->where('business_id', $validated['account_id'])
->firstOrFail();
}
// SECURITY: Verify deal belongs to business if provided
if (! empty($validated['deal_id'])) {
@@ -136,7 +164,7 @@ class QuoteController extends Controller
$quote->calculateTotals();
return redirect()->route('seller.crm.quotes.show', $quote)
return redirect()->route('seller.business.crm.quotes.show', [$business, $quote])
->with('success', 'Quote created successfully.');
}
@@ -235,12 +263,12 @@ class QuoteController extends Controller
$quote->calculateTotals();
return redirect()->route('seller.crm.quotes.show', $quote)
return redirect()->route('seller.business.crm.quotes.show', [$business, $quote])
->with('success', 'Quote updated successfully.');
}
/**
* Send quote to contact
* Send quote via email
*/
public function send(Request $request, Business $business, CrmQuote $quote)
{
@@ -248,17 +276,252 @@ class QuoteController extends Controller
abort(404);
}
if (! $quote->canBeSent()) {
return back()->withErrors(['error' => 'This quote cannot be sent.']);
$validated = $request->validate([
'to' => 'required|email',
'cc' => 'nullable|string',
'message' => 'nullable|string|max:2000',
'attach_pdf' => 'boolean',
]);
// Generate PDF if needed
$pdfPath = null;
if ($validated['attach_pdf'] ?? true) {
$pdfPath = $this->generateQuotePdf($quote, $business);
}
$quote->send($request->user());
// Send email
$ccEmails = [];
if (! empty($validated['cc'])) {
$ccEmails = array_map('trim', explode(',', $validated['cc']));
}
// TODO: Send email notification to contact
Mail::to($validated['to'])
->cc($ccEmails)
->send(new QuoteMail($quote, $business, $validated['message'] ?? null, $pdfPath));
// Update quote status if draft
if ($quote->status === CrmQuote::STATUS_DRAFT) {
$quote->send($request->user());
}
// Log activity
Activity::log(
sellerBusinessId: $business->id,
subject: $quote,
type: 'quote.emailed',
description: "Quote {$quote->quote_number} emailed to {$validated['to']}",
causer: $request->user(),
contactId: $quote->contact_id,
);
return back()->with('success', 'Quote sent successfully.');
}
/**
* Update quote status (accept/decline/expire)
*/
public function updateStatus(Request $request, Business $business, CrmQuote $quote)
{
if ($quote->business_id !== $business->id) {
abort(404);
}
$validated = $request->validate([
'status' => 'required|in:accepted,rejected,expired',
'note' => 'nullable|string|max:1000',
]);
$oldStatus = $quote->status;
if ($validated['status'] === 'accepted') {
$quote->accept();
} elseif ($validated['status'] === 'rejected') {
$quote->reject($validated['note'] ?? 'Declined by seller');
} else {
$quote->update([
'status' => CrmQuote::STATUS_EXPIRED,
]);
}
Activity::log(
sellerBusinessId: $business->id,
subject: $quote,
type: 'quote.status_changed',
description: "Quote {$quote->quote_number} status changed from {$oldStatus} to {$validated['status']}",
causer: $request->user(),
contactId: $quote->contact_id,
);
return back()->with('success', 'Quote status updated.');
}
/**
* Convert quote to order
*/
public function convertToOrder(Request $request, Business $business, CrmQuote $quote)
{
if ($quote->business_id !== $business->id) {
abort(404);
}
if ($quote->order_id) {
return back()->withErrors(['error' => 'This quote already has an order.']);
}
$validated = $request->validate([
'also_create_invoice' => 'boolean',
]);
// Create order from quote
$orderNumber = 'ORD-'.strtoupper(uniqid());
$order = Order::create([
'order_number' => $orderNumber,
'business_id' => $quote->account_id, // Buyer business
'seller_business_id' => $business->id,
'contact_id' => $quote->contact_id,
'user_id' => $request->user()->id,
'subtotal' => $quote->subtotal,
'surcharge' => 0,
'tax' => $quote->tax_amount,
'total' => $quote->total,
'status' => 'new',
'created_by' => 'seller',
'payment_terms' => 'net_30',
'notes' => $quote->notes,
]);
// Copy line items
foreach ($quote->items as $item) {
OrderItem::create([
'order_id' => $order->id,
'product_id' => $item->product_id,
'quantity' => $item->quantity,
'unit_price' => $item->unit_price,
'line_total' => $item->line_total,
'product_name' => $item->product?->name ?? $item->description,
'product_sku' => $item->product?->sku ?? '',
'brand_name' => $item->product?->brand?->name ?? '',
]);
}
// Link quote to order and update status
$quote->update([
'order_id' => $order->id,
'status' => CrmQuote::STATUS_ACCEPTED,
'accepted_at' => now(),
]);
// Log activity
Activity::log(
sellerBusinessId: $business->id,
subject: $quote,
type: 'quote.converted_to_order',
description: "Quote {$quote->quote_number} converted to Order {$orderNumber}",
causer: $request->user(),
contactId: $quote->contact_id,
);
// Optionally create invoice
if ($validated['also_create_invoice'] ?? false) {
$invoice = $quote->convertToInvoice();
return redirect()->route('seller.business.crm.invoices.show', [$business, $invoice])
->with('success', 'Order and invoice created from quote.');
}
return redirect()->route('seller.business.orders.show', [$business, $order])
->with('success', 'Order created from quote.');
}
/**
* Generate invoice from quote (or its order)
*/
public function generateInvoice(Request $request, Business $business, CrmQuote $quote, ArService $arService)
{
if ($quote->business_id !== $business->id) {
abort(404);
}
if ($quote->invoice) {
return back()->withErrors(['error' => 'This quote already has an invoice.']);
}
// Credit check if there's a buyer account
if ($quote->account_id) {
$buyerBusiness = Business::find($quote->account_id);
if ($buyerBusiness) {
$creditCheck = $arService->checkCreditForAccount(
$business,
$buyerBusiness,
(float) $quote->total
);
if (! $creditCheck['can_extend']) {
return back()->withErrors([
'error' => 'Cannot create invoice: '.$creditCheck['reason'],
]);
}
}
}
$invoice = $quote->convertToInvoice();
Activity::log(
sellerBusinessId: $business->id,
subject: $quote,
type: 'quote.invoice_generated',
description: "Invoice {$invoice->invoice_number} generated from Quote {$quote->quote_number}",
causer: $request->user(),
contactId: $quote->contact_id,
);
return redirect()->route('seller.business.crm.invoices.show', [$business, $invoice])
->with('success', 'Invoice created from quote.');
}
/**
* Generate and store quote PDF
*/
protected function generateQuotePdf(CrmQuote $quote, Business $business): ?string
{
$quote->load(['contact', 'account', 'items.product.brand', 'business']);
$pdf = Pdf::loadView('pdfs.crm-quote', [
'quote' => $quote,
'business' => $business,
'sellerBusiness' => $business,
]);
$filename = "quotes/{$quote->quote_number}.pdf";
Storage::put($filename, $pdf->output());
$quote->update(['pdf_path' => $filename]);
return $filename;
}
/**
* View quote PDF
*/
public function pdf(Request $request, Business $business, CrmQuote $quote)
{
if ($quote->business_id !== $business->id) {
abort(404);
}
$quote->load(['contact', 'account', 'items.product.brand', 'business']);
$pdf = Pdf::loadView('pdfs.crm-quote', [
'quote' => $quote,
'business' => $business,
'sellerBusiness' => $business,
]);
return $pdf->inline("{$quote->quote_number}.pdf");
}
/**
* Convert quote to invoice
*/
@@ -302,7 +565,7 @@ class QuoteController extends Controller
$invoice = $quote->convertToInvoice();
return redirect()->route('seller.crm.invoices.show', $invoice)
return redirect()->route('seller.business.crm.invoices.show', [$business, $invoice])
->with('success', 'Invoice created from quote.');
}
@@ -330,7 +593,7 @@ class QuoteController extends Controller
$quote->delete();
return redirect()->route('seller.crm.quotes.index')
return redirect()->route('seller.business.crm.quotes.index', $business)
->with('success', 'Quote deleted.');
}
}

View File

@@ -40,8 +40,30 @@ class TaskController extends Controller
$tasksQuery->where('type', $request->type);
}
// Search filter
if ($request->filled('q')) {
$search = $request->q;
$tasksQuery->where(function ($q) use ($search) {
$q->where('title', 'ILIKE', "%{$search}%")
->orWhere('details', 'ILIKE', "%{$search}%");
});
}
$tasks = $tasksQuery->paginate(25);
// Return JSON for AJAX/API requests (live search)
if ($request->wantsJson()) {
return response()->json([
'data' => $tasks->map(fn ($t) => [
'id' => $t->id,
'name' => $t->title,
'type' => $t->type,
'assignee' => $t->assignee?->name ?? 'Unassigned',
'due_at' => $t->due_at?->format('M j, Y'),
])->values()->toArray(),
]);
}
// Get stats with single efficient query
$statsQuery = CrmTask::where('seller_business_id', $business->id)
->selectRaw('
@@ -62,7 +84,12 @@ class TaskController extends Controller
// Get team members for assignment filter
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))->get();
return view('seller.crm.tasks.index', compact('business', 'tasks', 'counts', 'teamMembers'));
// Get buyer businesses (accounts) for filtering
$buyerBusinesses = Business::where('type', 'buyer')
->orderBy('name')
->get(['id', 'name']);
return view('seller.crm.tasks.index', compact('business', 'tasks', 'counts', 'teamMembers', 'buyerBusinesses'));
}
/**

View File

@@ -22,13 +22,113 @@ class ThreadController extends Controller
protected CrmAiService $aiService
) {}
/**
* Show compose form for new thread
*/
public function create(Request $request, Business $business)
{
// Get customer business IDs (businesses that have ordered from this seller)
$customerBusinessIds = \App\Models\Order::whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
->pluck('business_id')
->unique();
// Get contacts from customer businesses (accounts)
$contacts = \App\Models\Contact::whereIn('business_id', $customerBusinessIds)
->with('business:id,name')
->orderBy('first_name')
->limit(200)
->get();
// Get available channels
$channels = $this->channelService->getAvailableChannels($business->id);
// Pre-select contact if provided
$selectedContact = null;
if ($request->filled('contact_id')) {
$selectedContact = \App\Models\Contact::whereIn('business_id', $customerBusinessIds)
->find($request->contact_id);
}
return view('seller.crm.threads.create', compact('business', 'contacts', 'channels', 'selectedContact'));
}
/**
* Store a new thread and send initial message
*/
public function store(Request $request, Business $business)
{
$validated = $request->validate([
'contact_id' => 'required|exists:contacts,id',
'channel_type' => 'required|string|in:sms,email,whatsapp,instagram,in_app',
'subject' => 'nullable|string|max:255',
'body' => 'required|string|max:10000',
'attachments.*' => 'nullable|file|max:10240',
]);
// Get customer business IDs (businesses that have ordered from this seller)
$customerBusinessIds = \App\Models\Order::whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
->pluck('business_id')
->unique();
// SECURITY: Verify contact belongs to a customer business
$contact = \App\Models\Contact::whereIn('business_id', $customerBusinessIds)
->findOrFail($validated['contact_id']);
// Determine recipient address
$to = $validated['channel_type'] === CrmChannel::TYPE_EMAIL
? $contact->email
: $contact->phone;
if (! $to) {
return back()->withInput()->withErrors([
'channel_type' => 'Contact does not have the required contact info for this channel.',
]);
}
// Create thread first
$thread = CrmThread::create([
'business_id' => $business->id,
'contact_id' => $contact->id,
'account_id' => $contact->account_id,
'subject' => $validated['subject'],
'status' => 'open',
'priority' => 'normal',
'last_channel_type' => $validated['channel_type'],
'assigned_to' => $request->user()->id,
]);
// Send the message
$success = $this->channelService->sendMessage(
businessId: $business->id,
channelType: $validated['channel_type'],
to: $to,
body: $validated['body'],
subject: $validated['subject'] ?? null,
threadId: $thread->id,
contactId: $contact->id,
userId: $request->user()->id,
attachments: $request->file('attachments', [])
);
if (! $success) {
// Delete the thread if message failed
$thread->delete();
return back()->withInput()->withErrors(['body' => 'Failed to send message.']);
}
return redirect()
->route('seller.business.crm.threads.show', [$business, $thread])
->with('success', 'Conversation started successfully.');
}
/**
* Display unified inbox
*/
public function index(Request $request, Business $business)
{
$query = CrmThread::forBusiness($business->id)
->with(['contact', 'assignee', 'messages' => fn ($q) => $q->latest()->limit(1)])
->with(['contact', 'assignee', 'brand', 'channel', 'messages' => fn ($q) => $q->latest()->limit(1)])
->withCount('messages');
// Filters
@@ -52,6 +152,16 @@ class ThreadController extends Controller
$query->withPriority($request->priority);
}
// Department filter
if ($request->filled('department')) {
$query->forDepartment($request->department);
}
// Brand filter
if ($request->filled('brand_id')) {
$query->forBrand($request->brand_id);
}
if ($request->filled('search')) {
$query->where(function ($q) use ($request) {
$q->where('subject', 'like', "%{$request->search}%")
@@ -70,7 +180,16 @@ class ThreadController extends Controller
// Get available channels
$channels = $this->channelService->getAvailableChannels($business->id);
return view('seller.crm.threads.index', compact('business', 'threads', 'teamMembers', 'channels'));
// Get brands for filter dropdown
$brands = \App\Models\Brand::where('business_id', $business->id)
->where('is_active', true)
->orderBy('name')
->get();
// Get departments for filter dropdown
$departments = CrmChannel::DEPARTMENTS;
return view('seller.crm.threads.index', compact('business', 'threads', 'teamMembers', 'channels', 'brands', 'departments'));
}
/**
@@ -88,6 +207,8 @@ class ThreadController extends Controller
'contact',
'account',
'assignee',
'brand',
'channel',
'messages.attachments',
'messages.user',
'deals',
@@ -168,6 +289,12 @@ class ThreadController extends Controller
return back()->withErrors(['body' => 'Failed to send message.']);
}
// Auto-assign thread to sender if unassigned
if ($thread->assigned_to === null) {
$thread->assigned_to = $request->user()->id;
$thread->save();
}
// Handle SLA
$this->slaService->handleOutboundMessage($thread);

View File

@@ -22,6 +22,7 @@ class EmailSettingsController extends Controller
'business' => $business,
'settings' => $settings,
'drivers' => BusinessMailSettings::DRIVERS,
'providers' => BusinessMailSettings::PROVIDERS,
'encryptions' => BusinessMailSettings::ENCRYPTIONS,
'commonPorts' => BusinessMailSettings::COMMON_PORTS,
]);
@@ -34,6 +35,7 @@ class EmailSettingsController extends Controller
{
$validated = $request->validate([
'driver' => ['required', 'string', Rule::in(array_keys(BusinessMailSettings::DRIVERS))],
'provider' => ['required', 'string', Rule::in(array_keys(BusinessMailSettings::PROVIDERS))],
'host' => ['nullable', 'string', 'max:255'],
'port' => ['nullable', 'integer', 'min:1', 'max:65535'],
'encryption' => ['nullable', 'string', Rule::in(['tls', 'ssl', ''])],
@@ -43,6 +45,9 @@ class EmailSettingsController extends Controller
'from_email' => ['nullable', 'email', 'max:255'],
'reply_to_email' => ['nullable', 'email', 'max:255'],
'is_active' => ['boolean'],
// Postal-specific config fields
'postal_server_url' => ['nullable', 'url', 'max:255'],
'postal_webhook_secret' => ['nullable', 'string', 'max:255'],
]);
// Handle empty encryption value
@@ -55,6 +60,21 @@ class EmailSettingsController extends Controller
unset($validated['password']);
}
// Build provider_config from provider-specific fields
$providerConfig = [];
if ($validated['provider'] === BusinessMailSettings::PROVIDER_POSTAL) {
if (! empty($validated['postal_server_url'])) {
$providerConfig['server_url'] = $validated['postal_server_url'];
}
if (! empty($validated['postal_webhook_secret'])) {
$providerConfig['webhook_secret'] = $validated['postal_webhook_secret'];
}
}
$validated['provider_config'] = ! empty($providerConfig) ? $providerConfig : null;
// Remove provider-specific fields from main validated array
unset($validated['postal_server_url'], $validated['postal_webhook_secret']);
$settings = BusinessMailSettings::getOrCreate($business);
$settings->update($validated);

View File

@@ -3,10 +3,13 @@
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Mail\Invoices\InvoiceSentMail;
use App\Models\Business;
use App\Models\Invoice;
use App\Models\InvoicePayment;
use App\Services\InvoiceService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\Response;
@@ -25,64 +28,7 @@ class InvoiceController extends Controller
->orderBy('name')
->get();
// Get all products from brands owned by this business with images, stock levels, and batches
$products = \App\Models\Product::forBusiness($business)
->where('is_active', true)
->with(['brand', 'images', 'availableBatches.labs'])
->select('id', 'brand_id', 'name', 'sku', 'description', 'wholesale_price', 'msrp_price',
'quantity_on_hand', 'quantity_allocated', 'type', 'image_path')
->orderBy('name')
->get()
->map(function ($product) use ($business) {
// Map batches with their COA data
$batches = $product->availableBatches->map(function ($batch) {
$latestLab = $batch->getLatestLab();
return [
'id' => $batch->id,
'batch_number' => $batch->batch_number,
'quantity_available' => $batch->quantity_available,
'production_date' => $batch->production_date?->format('M j, Y'),
'expiration_date' => $batch->expiration_date?->format('M j, Y'),
'is_expiring_soon' => $batch->isExpiringSoon(),
'lab' => $latestLab ? [
'total_thc' => $latestLab->total_thc,
'total_cbd' => $latestLab->total_cbd,
'test_date' => $latestLab->test_date->format('M j, Y'),
'lab_name' => $latestLab->lab_name,
'compliance_pass' => $latestLab->compliance_pass,
'terpene_profile' => $latestLab->terpene_profile,
] : null,
];
});
// 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,
'description' => $product->description,
'brand_name' => $product->brand?->name,
'wholesale_price' => $product->wholesale_price,
'msrp_price' => $product->msrp_price,
'quantity_on_hand' => $totalOnHand,
'quantity_allocated' => $totalAllocated,
'quantity_available' => max(0, $totalOnHand - $totalAllocated),
'type' => $product->type,
'image_url' => $product->images->first()?->path
? \Storage::url($product->images->first()->path)
: ($product->image_path ? \Storage::url($product->image_path) : null),
'batches' => $batches,
'has_batches' => $batches->count() > 0,
];
});
// Products are loaded via API search (/search/invoice-products) for better performance
// Get recently invoiced products (last 30 days, top 10 most common)
$recentProducts = \App\Models\Product::forBusiness($business)
@@ -118,7 +64,7 @@ class InvoiceController extends Controller
];
});
return view('seller.invoices.create', compact('business', 'buyers', 'products', 'recentProducts'));
return view('seller.invoices.create', compact('business', 'buyers', 'recentProducts'));
}
/**
@@ -172,7 +118,7 @@ class InvoiceController extends Controller
/**
* Display a listing of invoices for the business.
*/
public function index(Business $business)
public function index(Business $business, Request $request)
{
// Get brand IDs for this business (single query, reused for filtering)
$brandIds = $business->brands()->pluck('id');
@@ -192,11 +138,47 @@ class InvoiceController extends Controller
->where('due_date', '<', now())->count(),
];
// Apply search filter - search by customer business name or invoice number
$search = $request->input('search');
if ($search) {
$baseQuery->where(function ($query) use ($search) {
$query->where('invoice_number', 'ilike', "%{$search}%")
->orWhereHas('business', function ($q) use ($search) {
$q->where('name', 'ilike', "%{$search}%");
});
});
}
// Apply status filter
$status = $request->input('status');
if ($status === 'unpaid') {
$baseQuery->where('payment_status', 'unpaid');
} elseif ($status === 'paid') {
$baseQuery->where('payment_status', 'paid');
} elseif ($status === 'overdue') {
$baseQuery->where('payment_status', '!=', 'paid')
->where('due_date', '<', now());
}
// Paginate with only the relations needed for display
$invoices = (clone $baseQuery)
->with(['business:id,name,primary_contact_email,business_email', 'order:id,contact_id,user_id', 'order.contact:id,first_name,last_name,email', 'order.user:id,email'])
->latest()
->paginate(25);
->paginate(25)
->withQueryString();
// Return JSON for AJAX/API requests (live search)
if ($request->wantsJson()) {
return response()->json([
'data' => $invoices->map(fn ($i) => [
'hashid' => $i->hashid,
'name' => $i->invoice_number.' - '.$i->business->name,
'invoice_number' => $i->invoice_number,
'customer' => $i->business->name,
'status' => $i->payment_status,
])->values()->toArray(),
]);
}
return view('seller.invoices.index', compact('business', 'invoices', 'stats'));
}
@@ -207,7 +189,13 @@ class InvoiceController extends Controller
public function show(Business $business, Invoice $invoice)
{
// Verify invoice belongs to this business through order items
$invoice->load(['order.items.product.brand', 'business']);
$invoice->load([
'order.items.product.brand',
'order.contact',
'order.user',
'business',
'payments.recordedByUser',
]);
// Check if any of the order's items belong to brands owned by this business
$belongsToBusiness = $invoice->order->items->some(function ($item) use ($business) {
@@ -297,4 +285,102 @@ class InvoiceController extends Controller
'contacts' => $contacts,
]);
}
/**
* Send invoice by email.
*/
public function send(Business $business, Invoice $invoice, Request $request, InvoiceService $invoiceService): Response
{
// Verify invoice belongs to this business through order items
$invoice->load('order.items.product.brand');
$belongsToBusiness = $invoice->order->items->some(function ($item) use ($business) {
return $item->product && $item->product->belongsToBusiness($business);
});
if (! $belongsToBusiness) {
abort(403, 'This invoice does not belong to your business');
}
$validated = $request->validate([
'to' => ['required', 'email'],
'cc' => ['nullable', 'email'],
'message' => ['nullable', 'string', 'max:2000'],
'attach_pdf' => ['sometimes', 'boolean'],
]);
// Generate PDF if requested
$pdfContent = null;
if ($validated['attach_pdf'] ?? false) {
// Regenerate PDF if it doesn't exist
if (! $invoice->pdf_path || ! Storage::disk('local')->exists($invoice->pdf_path)) {
$invoiceService->regeneratePdf($invoice);
$invoice->refresh();
}
if ($invoice->pdf_path && Storage::disk('local')->exists($invoice->pdf_path)) {
$pdfContent = Storage::disk('local')->get($invoice->pdf_path);
}
}
// Send email
$mail = Mail::to($validated['to']);
if (! empty($validated['cc'])) {
$mail->cc($validated['cc']);
}
$mail->send(new InvoiceSentMail(
$invoice,
$validated['message'] ?? null,
$pdfContent
));
return back()->with('success', 'Invoice sent successfully to '.$validated['to']);
}
/**
* Record a payment for an invoice.
*/
public function recordPayment(Business $business, Invoice $invoice, Request $request): Response
{
// Verify invoice belongs to this business through order items
$invoice->load('order.items.product.brand');
$belongsToBusiness = $invoice->order->items->some(function ($item) use ($business) {
return $item->product && $item->product->belongsToBusiness($business);
});
if (! $belongsToBusiness) {
abort(403, 'This invoice does not belong to your business');
}
if ($invoice->payment_status === 'paid') {
return back()->withErrors(['error' => 'This invoice is already fully paid.']);
}
$validated = $request->validate([
'amount' => ['required', 'numeric', 'min:0.01', 'max:'.$invoice->amount_due],
'payment_date' => ['required', 'date'],
'payment_method' => ['required', 'string', 'in:cash,check,wire,ach,credit_card,bank_transfer,other'],
'reference' => ['nullable', 'string', 'max:255'],
'notes' => ['nullable', 'string', 'max:500'],
]);
InvoicePayment::create([
'invoice_id' => $invoice->id,
'amount' => $validated['amount'],
'payment_date' => $validated['payment_date'],
'payment_method' => $validated['payment_method'],
'reference' => $validated['reference'],
'notes' => $validated['notes'],
'recorded_by' => $request->user()->id,
]);
$statusMessage = $invoice->fresh()->payment_status === 'paid'
? 'Payment recorded. Invoice is now fully paid.'
: 'Payment recorded successfully.';
return back()->with('success', $statusMessage);
}
}

View File

@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
use App\Models\Brand;
use App\Models\Marketing\Campaign;
use App\Models\Marketing\MarketingChannel;
use App\Models\Marketing\MarketingPromo;
use App\Models\Marketing\MarketingTemplate;
use App\Services\AI\TemplatePromptBuilder;
use App\Services\Marketing\AIContentService;
@@ -45,13 +46,21 @@ class CampaignController extends Controller
$preselectedSegment = $request->query('segment');
$preselectedBrand = $request->query('brand_id');
// Pre-populate from Promo if promo_id provided
$promo = null;
if ($request->query('promo_id')) {
$promo = MarketingPromo::where('business_id', $business->id)
->find($request->query('promo_id'));
}
return view('seller.marketing.campaigns.create', compact(
'business',
'brands',
'channels',
'templates',
'preselectedSegment',
'preselectedBrand'
'preselectedBrand',
'promo'
));
}

View File

@@ -0,0 +1,148 @@
<?php
namespace App\Http\Controllers\Seller\Marketing;
use App\Http\Controllers\Controller;
use App\Models\Brand;
use App\Services\Marketing\MarketingIntelligenceService;
use Illuminate\Http\Request;
/**
* Marketing Intelligence Controller
*
* Displays market intelligence data from CannaiQ including:
* - Store-level metrics (pricing position, market share, trends)
* - Product metrics (velocity, pricing history, competitor positioning)
* - Competitor snapshots (out-of-stock, pricing, promotions)
*/
class IntelligenceController extends Controller
{
protected MarketingIntelligenceService $intelligence;
public function __construct(MarketingIntelligenceService $intelligence)
{
$this->intelligence = $intelligence;
}
/**
* Display the marketing intelligence dashboard
*/
public function index(Request $request)
{
$business = currentBusiness();
// Get brands for filtering
$brands = Brand::where('business_id', $business->id)
->orderBy('name')
->get();
// Get store external ID from business settings or request
$storeExternalId = $request->get('store_id', $business->cannaiq_store_id ?? null);
// Fetch intelligence data if store is configured
$storeMetrics = [];
$productMetrics = [];
$competitorSnapshot = [];
$trends = [];
if ($storeExternalId) {
$storeMetrics = $this->intelligence->getStoreIntelligence($business->id, $storeExternalId);
$productMetrics = $this->intelligence->getProductIntelligence($business->id, $storeExternalId, 20);
$competitorSnapshot = $this->intelligence->getCompetitorSnapshot($business->id, $storeExternalId);
$trends = $this->intelligence->getMarketTrends($business->id, $storeExternalId);
}
return view('seller.marketing.intelligence.index', compact(
'business',
'brands',
'storeExternalId',
'storeMetrics',
'productMetrics',
'competitorSnapshot',
'trends'
));
}
/**
* Display store-level intelligence details
*/
public function store(Request $request, $businessSlug, string $storeExternalId)
{
$business = currentBusiness();
$storeData = $this->intelligence->getStoreIntelligence($business->id, $storeExternalId);
$productMetrics = $this->intelligence->getProductIntelligence($business->id, $storeExternalId, 50);
$trends = $this->intelligence->getMarketTrends($business->id, $storeExternalId);
return view('seller.marketing.intelligence.store', compact(
'business',
'storeExternalId',
'storeData',
'productMetrics',
'trends'
));
}
/**
* Display product-level intelligence details
*/
public function product(Request $request, $businessSlug, string $productExternalId)
{
$business = currentBusiness();
// Get store context
$storeExternalId = $request->get('store_id', $business->cannaiq_store_id ?? null);
$productData = [];
$priceHistory = [];
$competitorPricing = [];
if ($storeExternalId) {
// Get product data from cached metrics
$allProducts = $this->intelligence->getProductIntelligence($business->id, $storeExternalId, 100);
$products = $allProducts['products'] ?? [];
// Find the specific product
$productData = collect($products)->firstWhere('product_id', $productExternalId) ?? [];
// Price history would come from historical snapshots
// For now, placeholder
$priceHistory = [];
$competitorPricing = [];
}
return view('seller.marketing.intelligence.product', compact(
'business',
'productExternalId',
'productData',
'priceHistory',
'competitorPricing'
));
}
/**
* Refresh intelligence data from CannaiQ
*/
public function refresh(Request $request, $businessSlug)
{
$business = currentBusiness();
$storeExternalId = $request->get('store_id', $business->cannaiq_store_id ?? null);
if (! $storeExternalId) {
return redirect()
->route('seller.business.marketing.intelligence.index', $business->slug)
->with('error', 'No store configured for intelligence data.');
}
$results = $this->intelligence->refreshIntelligence($business->id, $storeExternalId);
$successCount = count(array_filter($results));
$message = $successCount > 0
? "Intelligence data refreshed ({$successCount}/3 data sources updated)."
: 'Failed to refresh intelligence data. Please try again later.';
return redirect()
->route('seller.business.marketing.intelligence.index', $business->slug)
->with($successCount > 0 ? 'success' : 'error', $message);
}
}

View File

@@ -0,0 +1,338 @@
<?php
namespace App\Http\Controllers\Seller\Marketing;
use App\Http\Controllers\Controller;
use App\Jobs\SendMarketingCampaignJob;
use App\Models\Business;
use App\Models\Marketing\MarketingCampaign;
use App\Models\Marketing\MarketingList;
use App\Models\Marketing\MarketingPromo;
use App\Services\Messaging\EmailSender;
use App\Services\Messaging\SmsSender;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class MarketingCampaignController extends Controller
{
public function index(Request $request, Business $business): View
{
$query = MarketingCampaign::forBusiness($business->id)
->with('list')
->orderBy('created_at', 'desc');
if ($request->filled('status')) {
$query->where('status', $request->status);
}
if ($request->filled('channel')) {
$query->channel($request->channel);
}
$campaigns = $query->paginate(25)->withQueryString();
return view('seller.marketing.campaigns.index', [
'business' => $business,
'campaigns' => $campaigns,
'statuses' => MarketingCampaign::STATUSES,
'channels' => MarketingCampaign::CHANNELS,
'filters' => $request->only(['status', 'channel']),
]);
}
public function create(Request $request, Business $business): View
{
$lists = MarketingList::forBusiness($business->id)->get();
// Pre-fill from promo if source=promo
$prefill = [];
if ($request->source === 'promo' && $request->promo_id) {
$promo = MarketingPromo::forBusiness($business->id)->find($request->promo_id);
if ($promo) {
$prefill = $this->prefillFromPromo($promo, $request->channel ?? 'email');
}
}
return view('seller.marketing.campaigns.create', [
'business' => $business,
'lists' => $lists,
'channels' => MarketingCampaign::CHANNELS,
'prefill' => $prefill,
'source' => $request->source,
'sourceId' => $request->promo_id,
]);
}
public function store(Request $request, Business $business): RedirectResponse
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'channel' => 'required|in:email,sms,multi',
'marketing_list_id' => 'nullable|exists:marketing_lists,id',
'subject' => 'nullable|string|max:255',
'email_preview_text' => 'nullable|string|max:255',
'sms_body' => 'nullable|string|max:1600',
'email_body_html' => 'nullable|string',
'from_name' => 'nullable|string|max:255',
'from_email' => 'nullable|email|max:255',
'source_type' => 'nullable|string|in:manual,promo,automation',
'source_id' => 'nullable|integer',
]);
// Verify list belongs to business
if ($validated['marketing_list_id']) {
$list = MarketingList::where('business_id', $business->id)
->find($validated['marketing_list_id']);
if (! $list) {
return back()->withErrors(['marketing_list_id' => 'Invalid list selected.'])->withInput();
}
}
$campaign = MarketingCampaign::create([
'business_id' => $business->id,
'name' => $validated['name'],
'channel' => $validated['channel'],
'status' => MarketingCampaign::STATUS_DRAFT,
'marketing_list_id' => $validated['marketing_list_id'] ?? null,
'subject' => $validated['subject'] ?? null,
'email_preview_text' => $validated['email_preview_text'] ?? null,
'sms_body' => $validated['sms_body'] ?? null,
'email_body_html' => $validated['email_body_html'] ?? null,
'from_name' => $validated['from_name'] ?? null,
'from_email' => $validated['from_email'] ?? null,
'source_type' => $validated['source_type'] ?? MarketingCampaign::SOURCE_MANUAL,
'source_id' => $validated['source_id'] ?? null,
]);
return redirect()
->route('seller.business.marketing.campaigns.show', [$business, $campaign])
->with('success', 'Campaign created successfully.');
}
public function show(Business $business, MarketingCampaign $campaign): View
{
$this->authorizeCampaign($business, $campaign);
$campaign->load('list', 'messageLogs');
return view('seller.marketing.campaigns.show', [
'business' => $business,
'campaign' => $campaign,
]);
}
public function edit(Business $business, MarketingCampaign $campaign): View
{
$this->authorizeCampaign($business, $campaign);
if (! $campaign->canEdit()) {
return redirect()
->route('seller.business.marketing.campaigns.show', [$business, $campaign])
->with('error', 'Cannot edit a campaign that is sending or sent.');
}
$lists = MarketingList::forBusiness($business->id)->get();
return view('seller.marketing.campaigns.edit', [
'business' => $business,
'campaign' => $campaign,
'lists' => $lists,
'channels' => MarketingCampaign::CHANNELS,
]);
}
public function update(Request $request, Business $business, MarketingCampaign $campaign): RedirectResponse
{
$this->authorizeCampaign($business, $campaign);
if (! $campaign->canEdit()) {
return back()->with('error', 'Cannot edit a campaign that is sending or sent.');
}
$validated = $request->validate([
'name' => 'required|string|max:255',
'channel' => 'required|in:email,sms,multi',
'marketing_list_id' => 'nullable|exists:marketing_lists,id',
'subject' => 'nullable|string|max:255',
'email_preview_text' => 'nullable|string|max:255',
'sms_body' => 'nullable|string|max:1600',
'email_body_html' => 'nullable|string',
'from_name' => 'nullable|string|max:255',
'from_email' => 'nullable|email|max:255',
]);
// Verify list belongs to business
if ($validated['marketing_list_id']) {
$list = MarketingList::where('business_id', $business->id)
->find($validated['marketing_list_id']);
if (! $list) {
return back()->withErrors(['marketing_list_id' => 'Invalid list selected.'])->withInput();
}
}
$campaign->update([
'name' => $validated['name'],
'channel' => $validated['channel'],
'marketing_list_id' => $validated['marketing_list_id'] ?? null,
'subject' => $validated['subject'] ?? null,
'email_preview_text' => $validated['email_preview_text'] ?? null,
'sms_body' => $validated['sms_body'] ?? null,
'email_body_html' => $validated['email_body_html'] ?? null,
'from_name' => $validated['from_name'] ?? null,
'from_email' => $validated['from_email'] ?? null,
]);
return redirect()
->route('seller.business.marketing.campaigns.show', [$business, $campaign])
->with('success', 'Campaign updated successfully.');
}
public function schedule(Request $request, Business $business, MarketingCampaign $campaign): RedirectResponse
{
$this->authorizeCampaign($business, $campaign);
if (! $campaign->canSchedule()) {
return back()->with('error', 'Cannot schedule this campaign.');
}
$validated = $request->validate([
'send_at' => 'required|date|after:now',
]);
$campaign->schedule(new \DateTime($validated['send_at']));
return back()->with('success', 'Campaign scheduled for '.date('M j, Y g:i A', strtotime($validated['send_at'])));
}
public function sendNow(Business $business, MarketingCampaign $campaign): RedirectResponse
{
$this->authorizeCampaign($business, $campaign);
if (! $campaign->canSend()) {
return back()->with('error', 'Cannot send this campaign. Make sure a list is selected and campaign is in draft status.');
}
SendMarketingCampaignJob::dispatch($campaign->id);
return back()->with('success', 'Campaign is now being sent.');
}
public function cancel(Business $business, MarketingCampaign $campaign): RedirectResponse
{
$this->authorizeCampaign($business, $campaign);
if (! $campaign->canCancel()) {
return back()->with('error', 'Cannot cancel this campaign.');
}
$campaign->cancel();
return back()->with('success', 'Campaign cancelled.');
}
public function testEmail(Request $request, Business $business, MarketingCampaign $campaign, EmailSender $emailSender): JsonResponse
{
$this->authorizeCampaign($business, $campaign);
$validated = $request->validate([
'email' => 'required|email',
]);
if (! $campaign->hasEmailContent()) {
return response()->json(['success' => false, 'message' => 'Campaign has no email content.']);
}
$result = $emailSender->sendTestEmail($campaign, $validated['email']);
return response()->json($result);
}
public function testSms(Request $request, Business $business, MarketingCampaign $campaign, SmsSender $smsSender): JsonResponse
{
$this->authorizeCampaign($business, $campaign);
$validated = $request->validate([
'phone' => 'required|string',
]);
if (! $campaign->hasSmsContent()) {
return response()->json(['success' => false, 'message' => 'Campaign has no SMS content.']);
}
$result = $smsSender->sendTestSms($campaign, $validated['phone']);
return response()->json($result);
}
public function destroy(Business $business, MarketingCampaign $campaign): RedirectResponse
{
$this->authorizeCampaign($business, $campaign);
if ($campaign->status === MarketingCampaign::STATUS_SENDING) {
return back()->with('error', 'Cannot delete a campaign that is currently sending.');
}
$campaign->delete();
return redirect()
->route('seller.business.marketing.campaigns.index', $business)
->with('success', 'Campaign deleted successfully.');
}
protected function authorizeCampaign(Business $business, MarketingCampaign $campaign): void
{
if ($campaign->business_id !== $business->id) {
abort(404);
}
}
protected function prefillFromPromo(MarketingPromo $promo, string $channel): array
{
$name = $promo->name.' - '.($channel === 'sms' ? 'SMS' : 'Email').' Blast';
$subject = $promo->name;
// Build simple description from promo
$description = $promo->description ?? '';
$dateRange = '';
if ($promo->start_date && $promo->end_date) {
$dateRange = 'Valid '.$promo->start_date->format('M j').' - '.$promo->end_date->format('M j');
}
// Simple email template
$emailHtml = <<<HTML
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>{$promo->name}</title>
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<h1 style="color: #1a1a1a;">{$promo->name}</h1>
<p>{$description}</p>
<p style="font-weight: bold; color: #059669;">{$dateRange}</p>
<p>Don't miss out on this limited time offer!</p>
</body>
</html>
HTML;
// Simple SMS
$smsBody = $promo->name;
if ($description) {
$smsBody .= ' - '.substr($description, 0, 100);
}
if ($dateRange) {
$smsBody .= '. '.$dateRange;
}
return [
'name' => $name,
'subject' => $subject,
'email_body_html' => $emailHtml,
'sms_body' => $smsBody,
'source_type' => MarketingCampaign::SOURCE_PROMO,
'source_id' => $promo->id,
];
}
}

View File

@@ -0,0 +1,183 @@
<?php
namespace App\Http\Controllers\Seller\Marketing;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Marketing\MarketingContact;
use App\Models\Marketing\MarketingList;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class MarketingContactController extends Controller
{
public function index(Request $request, Business $business): View
{
$query = MarketingContact::forBusiness($business->id)
->orderBy('created_at', 'desc');
if ($request->filled('type')) {
$query->ofType($request->type);
}
if ($request->filled('subscribed')) {
if ($request->subscribed === 'email') {
$query->subscribedEmail();
} elseif ($request->subscribed === 'sms') {
$query->subscribedSms();
}
}
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('email', 'ILIKE', "%{$search}%")
->orWhere('phone', 'ILIKE', "%{$search}%")
->orWhere('first_name', 'ILIKE', "%{$search}%")
->orWhere('last_name', 'ILIKE', "%{$search}%");
});
}
$contacts = $query->paginate(25)->withQueryString();
$lists = MarketingList::forBusiness($business->id)->get();
return view('seller.marketing.contacts.index', [
'business' => $business,
'contacts' => $contacts,
'lists' => $lists,
'types' => MarketingContact::TYPES,
'filters' => $request->only(['type', 'subscribed', 'search']),
]);
}
public function create(Business $business): View
{
return view('seller.marketing.contacts.create', [
'business' => $business,
'types' => MarketingContact::TYPES,
'sources' => MarketingContact::SOURCES,
]);
}
public function store(Request $request, Business $business): RedirectResponse
{
$validated = $request->validate([
'type' => 'required|in:buyer,consumer,internal',
'email' => 'nullable|email|max:255',
'phone' => 'nullable|string|max:20',
'first_name' => 'nullable|string|max:100',
'last_name' => 'nullable|string|max:100',
'tags' => 'nullable|array',
'is_subscribed_email' => 'boolean',
'is_subscribed_sms' => 'boolean',
]);
if (empty($validated['email']) && empty($validated['phone'])) {
return back()->withErrors(['email' => 'Either email or phone is required.'])->withInput();
}
$contact = MarketingContact::create([
'business_id' => $business->id,
'type' => $validated['type'],
'email' => $validated['email'] ?? null,
'phone' => $validated['phone'] ?? null,
'first_name' => $validated['first_name'] ?? null,
'last_name' => $validated['last_name'] ?? null,
'tags' => $validated['tags'] ?? [],
'source' => MarketingContact::SOURCE_MANUAL,
'is_subscribed_email' => $validated['is_subscribed_email'] ?? true,
'is_subscribed_sms' => $validated['is_subscribed_sms'] ?? true,
]);
return redirect()
->route('seller.business.marketing.contacts.index', $business)
->with('success', 'Contact created successfully.');
}
public function edit(Business $business, MarketingContact $contact): View
{
$this->authorizeContact($business, $contact);
return view('seller.marketing.contacts.edit', [
'business' => $business,
'contact' => $contact,
'types' => MarketingContact::TYPES,
'sources' => MarketingContact::SOURCES,
]);
}
public function update(Request $request, Business $business, MarketingContact $contact): RedirectResponse
{
$this->authorizeContact($business, $contact);
$validated = $request->validate([
'type' => 'required|in:buyer,consumer,internal',
'email' => 'nullable|email|max:255',
'phone' => 'nullable|string|max:20',
'first_name' => 'nullable|string|max:100',
'last_name' => 'nullable|string|max:100',
'tags' => 'nullable|array',
'is_subscribed_email' => 'boolean',
'is_subscribed_sms' => 'boolean',
]);
if (empty($validated['email']) && empty($validated['phone'])) {
return back()->withErrors(['email' => 'Either email or phone is required.'])->withInput();
}
$contact->update([
'type' => $validated['type'],
'email' => $validated['email'] ?? null,
'phone' => $validated['phone'] ?? null,
'first_name' => $validated['first_name'] ?? null,
'last_name' => $validated['last_name'] ?? null,
'tags' => $validated['tags'] ?? [],
'is_subscribed_email' => $validated['is_subscribed_email'] ?? true,
'is_subscribed_sms' => $validated['is_subscribed_sms'] ?? true,
]);
return redirect()
->route('seller.business.marketing.contacts.index', $business)
->with('success', 'Contact updated successfully.');
}
public function destroy(Business $business, MarketingContact $contact): RedirectResponse
{
$this->authorizeContact($business, $contact);
$contact->delete();
return redirect()
->route('seller.business.marketing.contacts.index', $business)
->with('success', 'Contact deleted successfully.');
}
public function addToList(Request $request, Business $business): RedirectResponse
{
$validated = $request->validate([
'contact_ids' => 'required|array',
'contact_ids.*' => 'integer|exists:marketing_contacts,id',
'list_id' => 'required|integer|exists:marketing_lists,id',
]);
$list = MarketingList::where('business_id', $business->id)
->findOrFail($validated['list_id']);
$contacts = MarketingContact::forBusiness($business->id)
->whereIn('id', $validated['contact_ids'])
->pluck('id');
$list->addContacts($contacts->toArray());
return back()->with('success', count($contacts).' contact(s) added to list.');
}
protected function authorizeContact(Business $business, MarketingContact $contact): void
{
if ($contact->business_id !== $business->id) {
abort(404);
}
}
}

View File

@@ -0,0 +1,128 @@
<?php
namespace App\Http\Controllers\Seller\Marketing;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Marketing\MarketingList;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class MarketingListController extends Controller
{
public function index(Business $business): View
{
$lists = MarketingList::forBusiness($business->id)
->withCount('contacts')
->orderBy('created_at', 'desc')
->paginate(25);
return view('seller.marketing.lists.index', [
'business' => $business,
'lists' => $lists,
]);
}
public function create(Business $business): View
{
return view('seller.marketing.lists.create', [
'business' => $business,
'types' => MarketingList::TYPES,
]);
}
public function store(Request $request, Business $business): RedirectResponse
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string|max:1000',
'type' => 'required|in:static,smart',
'filters' => 'nullable|array',
]);
MarketingList::create([
'business_id' => $business->id,
'name' => $validated['name'],
'description' => $validated['description'] ?? null,
'type' => $validated['type'],
'filters' => $validated['filters'] ?? null,
]);
return redirect()
->route('seller.business.marketing.lists.index', $business)
->with('success', 'List created successfully.');
}
public function show(Business $business, MarketingList $list): View
{
$this->authorizeList($business, $list);
$contacts = $list->getContacts()->paginate(25);
return view('seller.marketing.lists.show', [
'business' => $business,
'list' => $list,
'contacts' => $contacts,
]);
}
public function edit(Business $business, MarketingList $list): View
{
$this->authorizeList($business, $list);
return view('seller.marketing.lists.edit', [
'business' => $business,
'list' => $list,
'types' => MarketingList::TYPES,
]);
}
public function update(Request $request, Business $business, MarketingList $list): RedirectResponse
{
$this->authorizeList($business, $list);
$validated = $request->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string|max:1000',
'filters' => 'nullable|array',
]);
$list->update([
'name' => $validated['name'],
'description' => $validated['description'] ?? null,
'filters' => $validated['filters'] ?? null,
]);
return redirect()
->route('seller.business.marketing.lists.index', $business)
->with('success', 'List updated successfully.');
}
public function destroy(Business $business, MarketingList $list): RedirectResponse
{
$this->authorizeList($business, $list);
$list->delete();
return redirect()
->route('seller.business.marketing.lists.index', $business)
->with('success', 'List deleted successfully.');
}
public function removeContact(Business $business, MarketingList $list, int $contactId): RedirectResponse
{
$this->authorizeList($business, $list);
$list->removeContacts([$contactId]);
return back()->with('success', 'Contact removed from list.');
}
protected function authorizeList(Business $business, MarketingList $list): void
{
if ($list->business_id !== $business->id) {
abort(404);
}
}
}

View File

@@ -0,0 +1,382 @@
<?php
namespace App\Http\Controllers\Seller\Marketing;
use App\Http\Controllers\Controller;
use App\Models\Brand;
use App\Models\Marketing\MarketingPromo;
use App\Services\Marketing\PromoRecommendationService;
use Illuminate\Http\Request;
/**
* Promo Builder Controller
*
* Manages promotional offers including:
* - Creating promos with AI recommendations
* - Targeting stores, brands, or categories
* - Estimating lift and margin impact
* - Generating SMS/email copy
*/
class PromoController extends Controller
{
protected PromoRecommendationService $recommendations;
public function __construct(PromoRecommendationService $recommendations)
{
$this->recommendations = $recommendations;
}
/**
* Display list of all promos
*/
public function index(Request $request)
{
$business = currentBusiness();
$promos = MarketingPromo::forBusiness($business->id)
->when($request->status, fn ($q, $status) => $q->where('status', $status))
->when($request->type, fn ($q, $type) => $q->where('type', $type))
->when($request->brand_id, fn ($q, $brandId) => $q->where('brand_id', $brandId))
->with(['brand', 'creator'])
->orderByDesc('created_at')
->paginate(20);
$brands = Brand::where('business_id', $business->id)
->orderBy('name')
->get();
$promoTypes = MarketingPromo::getTypes();
$statuses = MarketingPromo::getStatuses();
return view('seller.marketing.promos.index', compact(
'business',
'promos',
'brands',
'promoTypes',
'statuses'
));
}
/**
* Show create promo form (Promo Builder wizard)
*/
public function create(Request $request)
{
$business = currentBusiness();
$brands = Brand::where('business_id', $business->id)
->orderBy('name')
->get();
$promoTypes = MarketingPromo::getTypes();
// Get AI recommendations
$storeExternalId = $request->get('store_id', $business->cannaiq_store_id ?? null);
$recommendations = $this->recommendations->getRecommendations($business->id, $storeExternalId);
return view('seller.marketing.promos.create', compact(
'business',
'brands',
'promoTypes',
'recommendations'
));
}
/**
* Store a new promo
*/
public function store(Request $request)
{
$business = currentBusiness();
$validated = $request->validate([
'name' => 'required|string|max:255',
'type' => 'required|in:'.implode(',', array_keys(MarketingPromo::getTypes())),
'brand_id' => 'nullable|exists:brands,id',
'store_external_id' => 'nullable|string|max:100',
'config' => 'required|array',
'expected_lift' => 'nullable|numeric|min:0|max:100',
'expected_margin_brand' => 'nullable|numeric',
'expected_margin_store' => 'nullable|numeric',
'starts_at' => 'nullable|date',
'ends_at' => 'nullable|date|after_or_equal:starts_at',
'description' => 'nullable|string|max:1000',
'sms_copy' => 'nullable|string|max:160',
'email_copy' => 'nullable|string|max:5000',
]);
// Verify brand belongs to business if provided
if ($validated['brand_id']) {
$brand = Brand::where('business_id', $business->id)
->where('id', $validated['brand_id'])
->first();
if (! $brand) {
abort(404, 'Brand not found');
}
}
$promo = MarketingPromo::create([
'business_id' => $business->id,
'name' => $validated['name'],
'type' => $validated['type'],
'brand_id' => $validated['brand_id'] ?? null,
'store_external_id' => $validated['store_external_id'] ?? null,
'config' => $validated['config'],
'expected_lift' => $validated['expected_lift'] ?? null,
'expected_margin_brand' => $validated['expected_margin_brand'] ?? null,
'expected_margin_store' => $validated['expected_margin_store'] ?? null,
'starts_at' => $validated['starts_at'] ?? null,
'ends_at' => $validated['ends_at'] ?? null,
'description' => $validated['description'] ?? null,
'sms_copy' => $validated['sms_copy'] ?? null,
'email_copy' => $validated['email_copy'] ?? null,
'status' => MarketingPromo::STATUS_DRAFT,
'created_by' => auth()->id(),
]);
return redirect()
->route('seller.business.marketing.promos.show', [$business->slug, $promo])
->with('success', 'Promo created successfully.');
}
/**
* Display a single promo
*/
public function show(Request $request, $businessSlug, MarketingPromo $promo)
{
$business = currentBusiness();
$this->authorizePromo($promo, $business);
$promo->load(['brand', 'creator']);
return view('seller.marketing.promos.show', compact('business', 'promo'));
}
/**
* Show edit form for a promo
*/
public function edit(Request $request, $businessSlug, MarketingPromo $promo)
{
$business = currentBusiness();
$this->authorizePromo($promo, $business);
$brands = Brand::where('business_id', $business->id)
->orderBy('name')
->get();
$promoTypes = MarketingPromo::getTypes();
return view('seller.marketing.promos.edit', compact(
'business',
'promo',
'brands',
'promoTypes'
));
}
/**
* Update a promo
*/
public function update(Request $request, $businessSlug, MarketingPromo $promo)
{
$business = currentBusiness();
$this->authorizePromo($promo, $business);
$validated = $request->validate([
'name' => 'required|string|max:255',
'brand_id' => 'nullable|exists:brands,id',
'store_external_id' => 'nullable|string|max:100',
'config' => 'nullable|array',
'expected_lift' => 'nullable|numeric|min:0|max:100',
'expected_margin_brand' => 'nullable|numeric',
'expected_margin_store' => 'nullable|numeric',
'starts_at' => 'nullable|date',
'ends_at' => 'nullable|date|after_or_equal:starts_at',
'description' => 'nullable|string|max:1000',
'sms_copy' => 'nullable|string|max:160',
'email_copy' => 'nullable|string|max:5000',
]);
// Verify brand belongs to business if provided
if ($validated['brand_id']) {
$brand = Brand::where('business_id', $business->id)
->where('id', $validated['brand_id'])
->first();
if (! $brand) {
abort(404, 'Brand not found');
}
}
$promo->update([
'name' => $validated['name'],
'brand_id' => $validated['brand_id'] ?? null,
'store_external_id' => $validated['store_external_id'] ?? null,
'config' => $validated['config'] ?? $promo->config,
'expected_lift' => $validated['expected_lift'] ?? $promo->expected_lift,
'expected_margin_brand' => $validated['expected_margin_brand'] ?? $promo->expected_margin_brand,
'expected_margin_store' => $validated['expected_margin_store'] ?? $promo->expected_margin_store,
'starts_at' => $validated['starts_at'] ?? $promo->starts_at,
'ends_at' => $validated['ends_at'] ?? $promo->ends_at,
'description' => $validated['description'] ?? $promo->description,
'sms_copy' => $validated['sms_copy'] ?? $promo->sms_copy,
'email_copy' => $validated['email_copy'] ?? $promo->email_copy,
]);
return redirect()
->route('seller.business.marketing.promos.show', [$business->slug, $promo])
->with('success', 'Promo updated successfully.');
}
/**
* Delete a promo
*/
public function destroy(Request $request, $businessSlug, MarketingPromo $promo)
{
$business = currentBusiness();
$this->authorizePromo($promo, $business);
$promo->delete();
return redirect()
->route('seller.business.marketing.promos.index', $business->slug)
->with('success', 'Promo deleted successfully.');
}
/**
* Activate a promo
*/
public function activate(Request $request, $businessSlug, MarketingPromo $promo)
{
$business = currentBusiness();
$this->authorizePromo($promo, $business);
$promo->activate();
return redirect()
->route('seller.business.marketing.promos.show', [$business->slug, $promo])
->with('success', 'Promo activated successfully.');
}
/**
* Cancel a promo
*/
public function cancel(Request $request, $businessSlug, MarketingPromo $promo)
{
$business = currentBusiness();
$this->authorizePromo($promo, $business);
$promo->cancel();
return redirect()
->route('seller.business.marketing.promos.show', [$business->slug, $promo])
->with('success', 'Promo cancelled.');
}
/**
* Duplicate a promo
*/
public function duplicate(Request $request, $businessSlug, MarketingPromo $promo)
{
$business = currentBusiness();
$this->authorizePromo($promo, $business);
$newPromo = $promo->replicate();
$newPromo->name = $promo->name.' (Copy)';
$newPromo->status = MarketingPromo::STATUS_DRAFT;
$newPromo->created_by = auth()->id();
$newPromo->save();
return redirect()
->route('seller.business.marketing.promos.edit', [$business->slug, $newPromo])
->with('success', 'Promo duplicated. Make your changes and save.');
}
/**
* Get AI recommendations for promo
*/
public function recommend(Request $request, $businessSlug)
{
$business = currentBusiness();
$storeExternalId = $request->get('store_id', $business->cannaiq_store_id ?? null);
$recommendations = $this->recommendations->getRecommendations($business->id, $storeExternalId);
return response()->json([
'success' => true,
'recommendations' => $recommendations,
]);
}
/**
* Estimate promo impact
*/
public function estimate(Request $request, $businessSlug)
{
$business = currentBusiness();
$validated = $request->validate([
'type' => 'required|in:'.implode(',', array_keys(MarketingPromo::getTypes())),
'config' => 'required|array',
'brand_id' => 'nullable|exists:brands,id',
'store_external_id' => 'nullable|string',
]);
$estimate = $this->recommendations->estimateImpact(
$validated,
$business->id,
$validated['store_external_id'] ?? $business->cannaiq_store_id ?? null
);
return response()->json([
'success' => true,
'estimate' => $estimate,
]);
}
/**
* Generate SMS/email copy for promo
*/
public function generateCopy(Request $request, $businessSlug)
{
$business = currentBusiness();
$validated = $request->validate([
'type' => 'required|in:'.implode(',', array_keys(MarketingPromo::getTypes())),
'config' => 'required|array',
'channel' => 'required|in:sms,email',
'brand_id' => 'nullable|exists:brands,id',
]);
// Get brand name for copy generation
$brandName = null;
if ($validated['brand_id']) {
$brand = Brand::where('business_id', $business->id)
->where('id', $validated['brand_id'])
->first();
$brandName = $brand?->name;
}
$promoConfig = array_merge($validated, ['brand_name' => $brandName ?? 'our products']);
$copy = $validated['channel'] === 'sms'
? $this->recommendations->generateSmsCopy($promoConfig)
: $this->recommendations->generateEmailCopy($promoConfig);
return response()->json([
'success' => true,
'copy' => $copy,
]);
}
/**
* Authorize that the promo belongs to the business
*/
protected function authorizePromo(MarketingPromo $promo, $business): void
{
if ($promo->business_id !== $business->id) {
abort(404);
}
}
}

View File

@@ -0,0 +1,240 @@
<?php
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Jobs\RunMarketingAutomationJob;
use App\Models\Business;
use App\Models\Marketing\MarketingAutomation;
use App\Models\Marketing\MarketingList;
use App\Models\Marketing\MarketingTemplate;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class MarketingAutomationController extends Controller
{
public function index(Request $request, Business $business)
{
$this->authorizeForBusiness($business);
$automations = MarketingAutomation::where('business_id', $business->id)
->with('latestRun')
->when($request->status === 'active', fn ($q) => $q->where('is_active', true))
->when($request->status === 'inactive', fn ($q) => $q->where('is_active', false))
->when($request->trigger_type, fn ($q, $type) => $q->where('trigger_type', $type))
->latest()
->paginate(15);
return view('seller.marketing.automations.index', compact('business', 'automations'));
}
public function create(Request $request, Business $business)
{
$this->authorizeForBusiness($business);
$presets = MarketingAutomation::getTypePresets();
$selectedPreset = $request->query('preset');
$lists = MarketingList::where('business_id', $business->id)
->withCount('contacts')
->orderBy('name')
->get();
$templates = MarketingTemplate::where('business_id', $business->id)
->where('is_active', true)
->orderBy('name')
->get();
return view('seller.marketing.automations.create', compact(
'business',
'presets',
'selectedPreset',
'lists',
'templates'
));
}
public function store(Request $request, Business $business)
{
$this->authorizeForBusiness($business);
$validated = $request->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string|max:1000',
'scope' => 'required|in:internal,portal',
'trigger_type' => 'required|in:'.implode(',', array_keys(MarketingAutomation::TRIGGER_TYPES)),
'trigger_config' => 'required|json',
'condition_config' => 'required|json',
'action_config' => 'required|json',
]);
// Decode JSON configs from the form
$triggerConfig = json_decode($validated['trigger_config'], true) ?? [];
$conditionConfig = json_decode($validated['condition_config'], true) ?? [];
$actionConfig = json_decode($validated['action_config'], true) ?? [];
// Normalize condition config - convert percentage values
if (isset($conditionConfig['min_price_advantage']) && $conditionConfig['min_price_advantage'] > 1) {
$conditionConfig['min_price_advantage'] = $conditionConfig['min_price_advantage'] / 100;
}
// Map velocity_threshold to velocity_30d_threshold for slow mover clearance
if (isset($conditionConfig['velocity_threshold'])) {
$conditionConfig['velocity_30d_threshold'] = $conditionConfig['velocity_threshold'];
unset($conditionConfig['velocity_threshold']);
}
$automation = MarketingAutomation::create([
'business_id' => $business->id,
'name' => $validated['name'],
'description' => $validated['description'],
'is_active' => true,
'scope' => $validated['scope'],
'trigger_type' => $validated['trigger_type'],
'trigger_config' => $triggerConfig,
'condition_config' => $conditionConfig,
'action_config' => $actionConfig,
]);
return redirect()
->route('seller.business.marketing.automations.index', $business)
->with('success', "Automation \"{$automation->name}\" created successfully.");
}
public function edit(Request $request, Business $business, MarketingAutomation $automation)
{
$this->authorizeForBusiness($business);
$this->ensureAutomationBelongsToBusiness($automation, $business);
$presets = MarketingAutomation::getTypePresets();
$lists = MarketingList::where('business_id', $business->id)
->withCount('contacts')
->orderBy('name')
->get();
$templates = MarketingTemplate::where('business_id', $business->id)
->where('is_active', true)
->orderBy('name')
->get();
return view('seller.marketing.automations.edit', compact(
'business',
'automation',
'presets',
'lists',
'templates'
));
}
public function update(Request $request, Business $business, MarketingAutomation $automation)
{
$this->authorizeForBusiness($business);
$this->ensureAutomationBelongsToBusiness($automation, $business);
$validated = $request->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string|max:1000',
'scope' => 'required|in:internal,portal',
'trigger_type' => 'required|in:'.implode(',', array_keys(MarketingAutomation::TRIGGER_TYPES)),
'trigger_config' => 'required|json',
'condition_config' => 'required|json',
'action_config' => 'required|json',
]);
// Decode JSON configs from the form
$triggerConfig = json_decode($validated['trigger_config'], true) ?? [];
$conditionConfig = json_decode($validated['condition_config'], true) ?? [];
$actionConfig = json_decode($validated['action_config'], true) ?? [];
// Normalize condition config - convert percentage values
if (isset($conditionConfig['min_price_advantage']) && $conditionConfig['min_price_advantage'] > 1) {
$conditionConfig['min_price_advantage'] = $conditionConfig['min_price_advantage'] / 100;
}
// Map velocity_threshold to velocity_30d_threshold for slow mover clearance
if (isset($conditionConfig['velocity_threshold'])) {
$conditionConfig['velocity_30d_threshold'] = $conditionConfig['velocity_threshold'];
unset($conditionConfig['velocity_threshold']);
}
$automation->update([
'name' => $validated['name'],
'description' => $validated['description'],
'scope' => $validated['scope'],
'trigger_type' => $validated['trigger_type'],
'trigger_config' => $triggerConfig,
'condition_config' => $conditionConfig,
'action_config' => $actionConfig,
]);
return redirect()
->route('seller.business.marketing.automations.index', $business)
->with('success', "Automation \"{$automation->name}\" updated successfully.");
}
public function toggle(Request $request, Business $business, MarketingAutomation $automation)
{
$this->authorizeForBusiness($business);
$this->ensureAutomationBelongsToBusiness($automation, $business);
$automation->update([
'is_active' => ! $automation->is_active,
]);
$status = $automation->is_active ? 'enabled' : 'disabled';
return redirect()
->back()
->with('success', "Automation \"{$automation->name}\" has been {$status}.");
}
public function runNow(Request $request, Business $business, MarketingAutomation $automation)
{
$this->authorizeForBusiness($business);
$this->ensureAutomationBelongsToBusiness($automation, $business);
if (! $automation->is_active) {
return redirect()
->back()
->with('error', 'Cannot run an inactive automation. Enable it first.');
}
// Dispatch the job
RunMarketingAutomationJob::dispatch($automation->id);
return redirect()
->route('seller.business.marketing.automations.runs.index', [$business, $automation])
->with('success', "Automation \"{$automation->name}\" has been queued to run.");
}
public function destroy(Request $request, Business $business, MarketingAutomation $automation)
{
$this->authorizeForBusiness($business);
$this->ensureAutomationBelongsToBusiness($automation, $business);
$name = $automation->name;
$automation->delete();
return redirect()
->route('seller.business.marketing.automations.index', $business)
->with('success', "Automation \"{$name}\" has been deleted.");
}
protected function authorizeForBusiness(Business $business): void
{
$user = Auth::user();
// Check user has access to this business
if (! $user->businesses->contains($business->id) && ! $user->hasRole('Super Admin')) {
abort(403, 'Unauthorized access to this business.');
}
}
protected function ensureAutomationBelongsToBusiness(MarketingAutomation $automation, Business $business): void
{
if ($automation->business_id !== $business->id) {
abort(404);
}
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Marketing\MarketingAutomation;
use App\Models\Marketing\MarketingAutomationRun;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class MarketingAutomationRunController extends Controller
{
public function index(Request $request, Business $business, MarketingAutomation $automation)
{
$this->authorizeForBusiness($business);
$this->ensureAutomationBelongsToBusiness($automation, $business);
$runs = MarketingAutomationRun::where('marketing_automation_id', $automation->id)
->when($request->status, fn ($q, $status) => $q->where('status', $status))
->orderBy('started_at', 'desc')
->paginate(25);
return view('seller.marketing.automations.runs.index', compact(
'business',
'automation',
'runs'
));
}
public function show(Request $request, Business $business, MarketingAutomation $automation, MarketingAutomationRun $run)
{
$this->authorizeForBusiness($business);
$this->ensureAutomationBelongsToBusiness($automation, $business);
$this->ensureRunBelongsToAutomation($run, $automation);
return view('seller.marketing.automations.runs.show', compact(
'business',
'automation',
'run'
));
}
protected function authorizeForBusiness(Business $business): void
{
$user = Auth::user();
if (! $user->businesses->contains($business->id) && ! $user->hasRole('Super Admin')) {
abort(403, 'Unauthorized access to this business.');
}
}
protected function ensureAutomationBelongsToBusiness(MarketingAutomation $automation, Business $business): void
{
if ($automation->business_id !== $business->id) {
abort(404);
}
}
protected function ensureRunBelongsToAutomation(MarketingAutomationRun $run, MarketingAutomation $automation): void
{
if ($run->marketing_automation_id !== $automation->id) {
abort(404);
}
}
}

View File

@@ -29,6 +29,11 @@ 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
$brands = \App\Models\Brand::where('business_id', $business->id)
->orderBy('name')
->get(['id', 'name']);
// Calculate missing BOM count for health alert
$missingBomCount = Product::whereIn('brand_id', $brandIds)
->where('is_assembly', true)
@@ -106,7 +111,7 @@ class ProductController extends Controller
'hashid' => $variety->hashid,
'name' => $variety->name,
'sku' => $variety->sku ?? 'N/A',
'price' => $variety->wholesale_price ?? 0,
'price' => $variety->effective_price ?? $variety->wholesale_price ?? 0,
'status' => $variety->is_active ? 'active' : 'inactive',
'image_url' => $variety->getImageUrl('thumb'),
'edit_url' => route('seller.business.products.edit', [$business->slug, $variety->hashid]),
@@ -123,7 +128,7 @@ class ProductController extends Controller
'sku' => $product->sku ?? 'N/A',
'brand' => $product->brand->name ?? 'N/A',
'channel' => 'Marketplace', // TODO: Add channel field to products
'price' => $product->wholesale_price ?? 0,
'price' => $product->effective_price ?? $product->wholesale_price ?? 0,
'views' => rand(500, 3000), // TODO: Replace with real view tracking
'orders' => rand(10, 200), // TODO: Replace with real order count
'revenue' => rand(1000, 10000), // TODO: Replace with real revenue calculation
@@ -150,7 +155,20 @@ class ProductController extends Controller
'to' => $paginator->lastItem(),
];
return view('seller.products.index', compact('business', 'products', 'missingBomCount', 'paginator', 'pagination'));
// Return JSON for AJAX/API requests (live search)
if ($request->wantsJson()) {
return response()->json([
'data' => $products->map(fn ($p) => [
'hashid' => $p['hashid'],
'name' => $p['product'],
'sku' => $p['sku'],
'brand' => $p['brand'],
])->values()->toArray(),
'pagination' => $pagination,
]);
}
return view('seller.products.index', compact('business', 'brands', 'products', 'missingBomCount', 'paginator', 'pagination'));
}
/**
@@ -450,8 +468,8 @@ class ProductController extends Controller
'category_id' => 'required|exists:product_categories,id',
'subcategory_id' => 'nullable|exists:product_categories,id',
'type' => 'nullable|string|in:flower,pre-roll,concentrate,edible,beverage,topical,tincture,vaporizer,other',
'wholesale_price' => 'required|numeric|min:0',
'price_unit' => 'required|string|in:each,gram,oz,lb,kg,ml,l',
'wholesale_price' => 'nullable|numeric|min:0',
'price_unit' => 'nullable|string|in:each,gram,oz,lb,kg,ml,l',
'net_weight' => 'nullable|numeric|min:0',
'weight_unit' => 'nullable|string|in:g,oz,lb,kg,ml,l',
'units_per_case' => 'nullable|integer|min:1',
@@ -472,20 +490,37 @@ class ProductController extends Controller
$validated['is_active'] = $request->has('is_active');
$validated['is_featured'] = $request->has('is_featured');
// Create product
$product = Product::create($validated);
// Set default value for price_unit if not provided
$validated['price_unit'] = $validated['price_unit'] ?? 'each';
// Handle image uploads if present
if ($request->hasFile('images')) {
foreach ($request->file('images') as $index => $image) {
$path = $image->store('products', 'public');
$product->images()->create([
'path' => $path,
'type' => 'product',
'is_primary' => $index === 0,
]);
// Create product and handle images in a transaction
$product = \DB::transaction(function () use ($validated, $request, $business) {
$product = Product::create($validated);
// Handle image uploads if present
// Note: Uses default disk (minio) per CLAUDE.md rules - never use 'public' disk for media
if ($request->hasFile('images')) {
$brand = $product->brand;
$basePath = "businesses/{$business->slug}/brands/{$brand->slug}/products/{$product->sku}/images";
foreach ($request->file('images') as $index => $image) {
$filename = $image->hashName();
$path = $image->storeAs($basePath, $filename);
if ($path === false) {
throw new \RuntimeException('Failed to upload image to storage');
}
$product->images()->create([
'path' => $path,
'type' => 'product',
'is_primary' => $index === 0,
]);
}
}
}
return $product;
});
return redirect()
->route('seller.business.products.index', $business->slug)
@@ -958,7 +993,7 @@ class ProductController extends Controller
// Update product
$product->update($validated);
// Return JSON for Precognition requests
// Return JSON for AJAX requests
if ($request->wantsJson()) {
return response()->json([
'message' => 'Product updated successfully!',
@@ -1029,7 +1064,7 @@ class ProductController extends Controller
'sku' => $product->sku ?? 'N/A',
'brand' => $product->brand->name ?? 'N/A',
'channel' => 'Marketplace', // TODO: Add channel field to products
'price' => $product->wholesale_price ?? 0,
'price' => (float) ($product->wholesale_price ?? 0),
'views' => rand(500, 3000), // TODO: Replace with real view tracking
'orders' => rand(10, 200), // TODO: Replace with real order count
'revenue' => rand(1000, 10000), // TODO: Replace with real revenue calculation

View File

@@ -50,15 +50,9 @@ class PromotionController extends Controller
->orderBy('name')
->get();
$products = \App\Models\Product::whereHas('brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})
->with('brand')
->where('is_active', true)
->orderBy('name')
->get();
// Products are loaded via API search (/search/products?brand_id=...) for better performance
return view('seller.promotions.create', compact('business', 'brands', 'products'));
return view('seller.promotions.create', compact('business', 'brands'));
}
public function store(Request $request, Business $business)
@@ -168,7 +162,13 @@ class PromotionController extends Controller
->with('products')
->findOrFail($id);
return view('seller.promotions.edit', compact('business', 'promotion'));
$brands = \App\Models\Brand::where('business_id', $business->id)
->orderBy('name')
->get();
$selectedProductIds = $promotion->products->pluck('id')->toArray();
return view('seller.promotions.edit', compact('business', 'promotion', 'brands', 'selectedProductIds'));
}
public function update(Request $request, Business $business, int $id)

View File

@@ -0,0 +1,266 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Contact;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Seller Search Controller
*
* Provides search endpoints for the search-select component.
* All endpoints return JSON in format: [{value: id, label: name}, ...]
*
* These are AJAX endpoints used by the search-select Alpine component.
*/
class SearchController extends Controller
{
/**
* Search customers (buyer businesses) for the current seller.
*
* GET /s/{business}/search/customers?q=...
*/
public function customers(Request $request, Business $business): JsonResponse
{
$query = $request->input('q', '');
// Search businesses that have placed orders with this seller
$customers = Business::query()
->whereHas('orders.items.product.brand', function ($q) use ($business) {
$q->where('business_id', $business->id);
})
->when($query, function ($q) use ($query) {
$q->where(function ($q2) use ($query) {
$q2->where('name', 'ILIKE', "%{$query}%")
->orWhere('business_email', 'ILIKE', "%{$query}%");
});
})
->orderBy('name')
->limit(25)
->get(['id', 'name', 'business_email']);
return response()->json(
$customers->map(fn ($c) => [
'value' => $c->id,
'label' => $c->name,
])
);
}
/**
* Search contacts for a specific customer or the seller's own contacts.
*
* GET /s/{business}/search/contacts?q=...&customer_id=...
*/
public function contacts(Request $request, Business $business): JsonResponse
{
$query = $request->input('q', '');
$customerId = $request->input('customer_id');
$contactsQuery = Contact::query()
->where('is_active', true);
// If customer_id is provided, search contacts for that customer
if ($customerId) {
$contactsQuery->where('business_id', $customerId);
} else {
// Otherwise, search contacts for the seller's business
$contactsQuery->where('business_id', $business->id);
}
$contacts = $contactsQuery
->when($query, function ($q) use ($query) {
$q->where(function ($q2) use ($query) {
$q2->where('first_name', 'ILIKE', "%{$query}%")
->orWhere('last_name', 'ILIKE', "%{$query}%")
->orWhere('email', 'ILIKE', "%{$query}%")
->orWhere('title', 'ILIKE', "%{$query}%");
});
})
->orderBy('first_name')
->orderBy('last_name')
->limit(25)
->get(['id', 'first_name', 'last_name', 'email', 'title']);
return response()->json(
$contacts->map(fn ($c) => [
'value' => $c->id,
'label' => $c->getFullName().($c->email ? " ({$c->email})" : ''),
])
);
}
/**
* Search accounts (CRM accounts / other businesses).
*
* GET /s/{business}/search/accounts?q=...
*/
public function accounts(Request $request, Business $business): JsonResponse
{
$query = $request->input('q', '');
// Search all businesses except the current one
$accounts = Business::query()
->where('id', '!=', $business->id)
->when($query, function ($q) use ($query) {
$q->where(function ($q2) use ($query) {
$q2->where('name', 'ILIKE', "%{$query}%")
->orWhere('business_email', 'ILIKE', "%{$query}%");
});
})
->orderBy('name')
->limit(25)
->get(['id', 'name', 'business_email', 'city', 'state']);
return response()->json(
$accounts->map(fn ($a) => [
'value' => $a->id,
'label' => $a->name.($a->city ? " ({$a->city}, {$a->state})" : ''),
])
);
}
/**
* Search products for the current business.
*
* GET /s/{business}/search/products?q=...&brand_id=...
*/
public function products(Request $request, Business $business): JsonResponse
{
$query = $request->input('q', '');
$brandId = $request->input('brand_id');
$products = \App\Models\Product::whereHas('brand', fn ($q) => $q->where('business_id', $business->id))
->where('is_active', true)
->with('brand:id,name')
->when($brandId, fn ($q) => $q->where('brand_id', $brandId))
->when($query, function ($q) use ($query) {
$q->where(function ($q2) use ($query) {
$q2->where('name', 'ILIKE', "%{$query}%")
->orWhere('sku', 'ILIKE', "%{$query}%")
->orWhereHas('brand', fn ($b) => $b->where('name', 'ILIKE', "%{$query}%"));
});
})
->orderBy('name')
->limit(100)
->get(['id', 'brand_id', 'name', 'sku', 'wholesale_price']);
return response()->json(
$products->map(fn ($p) => [
'value' => $p->id,
'label' => $p->name.' ('.$p->sku.')',
'id' => $p->id,
'brand_id' => $p->brand_id,
'brand_name' => $p->brand?->name,
'name' => $p->name,
'sku' => $p->sku,
'wholesale_price' => $p->wholesale_price ?? 0,
])
);
}
/**
* Search products with full details for invoices (includes batches, stock, images).
*
* GET /s/{business}/search/invoice-products?q=...&type=...&in_stock=1
*/
public function invoiceProducts(Request $request, Business $business): JsonResponse
{
$query = $request->input('q', '');
$type = $request->input('type', '');
$inStockOnly = $request->boolean('in_stock', false);
$products = \App\Models\Product::forBusiness($business)
->where('is_active', true)
->with(['brand', 'availableBatches.labs'])
->when($query, function ($q) use ($query) {
$q->where(function ($q2) use ($query) {
$q2->where('name', 'ILIKE', "%{$query}%")
->orWhere('sku', 'ILIKE', "%{$query}%")
->orWhere('description', 'ILIKE', "%{$query}%");
});
})
->when($type, fn ($q) => $q->where('type', $type))
->orderBy('name')
->limit(50)
->get()
->map(function ($product) use ($business) {
// Calculate inventory
$totalOnHand = $product->inventoryItems()
->where('business_id', $business->id)
->sum('quantity_on_hand');
// Map batches with COA data
$batches = $product->availableBatches->map(function ($batch) {
$latestLab = $batch->getLatestLab();
return [
'id' => $batch->id,
'batch_number' => $batch->batch_number,
'quantity_available' => $batch->quantity_available,
'expiration_date' => $batch->expiration_date?->format('Y-m-d'),
'has_coa' => $latestLab !== null,
'thc_total' => $latestLab?->thc_total,
'cbd_total' => $latestLab?->cbd_total,
];
});
return [
'id' => $product->id,
'name' => $product->name,
'sku' => $product->sku,
'type' => $product->type,
'description' => $product->description,
'wholesale_price' => $product->wholesale_price ?? 0,
'msrp_price' => $product->msrp_price,
'brand_name' => $product->brand?->name,
'image_url' => $product->getImageUrl('thumb'),
'quantity_available' => $totalOnHand,
'has_batches' => $batches->isNotEmpty(),
'batches' => $batches,
];
});
// Filter by stock if requested
if ($inStockOnly) {
$products = $products->filter(fn ($p) => $p['quantity_available'] > 0)->values();
}
return response()->json($products);
}
/**
* Search users/team members for the current business.
*
* GET /s/{business}/search/users?q=...
*/
public function users(Request $request, Business $business): JsonResponse
{
$query = $request->input('q', '');
$users = $business->users()
->when($query, function ($q) use ($query) {
$q->where(function ($q2) use ($query) {
$q2->where('first_name', 'ILIKE', "%{$query}%")
->orWhere('last_name', 'ILIKE', "%{$query}%")
->orWhere('email', 'ILIKE', "%{$query}%");
});
})
->orderBy('first_name')
->orderBy('last_name')
->limit(25)
->get(['users.id', 'first_name', 'last_name', 'email']);
return response()->json(
$users->map(fn ($u) => [
'value' => $u->id,
'label' => trim("{$u->first_name} {$u->last_name}") ?: $u->email,
])
);
}
}

View File

@@ -2,46 +2,168 @@
namespace App\Http\Controllers;
use App\Models\Brand;
use App\Models\Contact;
use App\Services\ConversationService;
use App\Services\Crm\CrmChannelService;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;
/**
* SMS Webhook Controller
*
* Handles inbound SMS from our SMS gateway.
* Routes messages to the CRM unified inbox (crm_threads + crm_channel_messages).
*
* This controller normalizes gateway-specific payloads into a provider-agnostic
* format before passing to CrmChannelService.
*/
class SmsController extends Controller
{
public function inbound(Request $request)
public function __construct(
protected CrmChannelService $crmChannelService,
) {}
/**
* Handle inbound SMS from our gateway.
*
* Expected gateway payload fields (adjust to match your actual gateway):
* - to: our phone number (identifies business/channel)
* - from: sender phone number
* - text: message text
* - message_id: unique message ID from gateway
* - timestamp: message timestamp (optional)
* - media: array of media URLs (optional)
*
* The controller normalizes this to our standard format before
* passing to the CRM service.
*/
public function inbound(Request $request): Response
{
$to = $request->input('To'); // brand SMS number
$from = $request->input('From'); // buyer phone
$body = $request->input('Body');
Log::info('SMS gateway inbound received', [
'to' => $request->input('to'),
'from' => $request->input('from'),
'body_preview' => substr($request->input('text', ''), 0, 50),
]);
// find brand by sms_number
$brand = Brand::where('sms_number', $to)->first();
if (! $brand) {
return;
try {
// Normalize gateway payload to our standard format
$payload = [
'provider' => 'gateway',
'to_number' => $this->normalizePhoneNumber($request->input('to')),
'from_number' => $this->normalizePhoneNumber($request->input('from')),
'body' => $request->input('text'),
'external_message_id' => $request->input('message_id'),
'meta' => $request->all(),
];
// Handle media attachments if present
$media = $request->input('media', []);
if (! empty($media)) {
$payload['attachments'] = [];
foreach ($media as $item) {
$payload['attachments'][] = [
'url' => $item['url'] ?? $item,
'content_type' => $item['content_type'] ?? 'application/octet-stream',
];
}
}
$message = $this->crmChannelService->receiveInboundSms($payload);
if ($message) {
Log::info("SMS gateway: Created CRM message {$message->id}");
}
// Return simple OK response
return response('OK', 200);
} catch (\Exception $e) {
Log::error('SMS gateway inbound error: '.$e->getMessage(), [
'exception' => $e,
]);
// Still return 200 to prevent gateway retries
return response('OK', 200);
}
}
/**
* Handle delivery status callback from our gateway.
*
* Expected gateway payload:
* - message_id: the external message ID
* - status: delivery status (sent, delivered, failed, etc.)
* - error_code: error code if failed (optional)
* - error_message: error description (optional)
*/
public function status(Request $request): Response
{
Log::info('SMS gateway status callback', [
'message_id' => $request->input('message_id'),
'status' => $request->input('status'),
]);
try {
$externalMessageId = $request->input('message_id');
$gatewayStatus = $request->input('status');
$errorCode = $request->input('error_code');
$errorMessage = $request->input('error_message');
if ($externalMessageId && $gatewayStatus) {
// Normalize gateway status to our standard statuses
$status = $this->normalizeStatus($gatewayStatus);
if ($status) {
$error = $errorCode ? "{$errorCode}: {$errorMessage}" : $errorMessage;
$this->crmChannelService->updateMessageStatus($externalMessageId, $status, $error);
}
}
return response('OK', 200);
} catch (\Exception $e) {
Log::error('SMS gateway status callback error: '.$e->getMessage());
return response('OK', 200);
}
}
/**
* Normalize gateway-specific status to our standard statuses.
*
* Adjust this mapping based on your gateway's status values.
*/
protected function normalizeStatus(string $gatewayStatus): ?string
{
// Common status mappings - adjust for your specific gateway
return match (strtolower($gatewayStatus)) {
'sent', 'queued', 'accepted', 'pending' => 'sent',
'delivered', 'delivery_success' => 'delivered',
'read', 'seen' => 'read',
'failed', 'undelivered', 'rejected', 'error', 'delivery_failed' => 'failed',
default => null,
};
}
/**
* Normalize phone number to E.164 format.
*/
protected function normalizePhoneNumber(?string $number): ?string
{
if (! $number) {
return null;
}
// find contact by phone
$contact = Contact::where('phone', $from)->first();
if (! $contact) {
return;
// Remove all non-numeric characters except leading +
$number = preg_replace('/[^0-9+]/', '', $number);
// Ensure it starts with +
if (! str_starts_with($number, '+')) {
// Assume US number if 10 digits
if (strlen($number) === 10) {
$number = '+1'.$number;
} elseif (strlen($number) === 11 && str_starts_with($number, '1')) {
$number = '+'.$number;
}
}
$service = app(ConversationService::class);
$conversation = $service->findOrCreateConversation($brand->id, $contact->id);
$service->storeMessage(
$conversation,
'sms',
'inbound',
$body,
[
'from_phone' => $from,
'to_phone' => $to,
]
);
$conversation->update(['last_message_at' => now()]);
return $number;
}
}

View File

@@ -0,0 +1,232 @@
<?php
namespace App\Http\Controllers\Webhook;
use App\Http\Controllers\Controller;
use App\Services\Crm\CrmChannelService;
use App\Services\Email\InboundEmailService;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;
/**
* Postal Email Webhook Controller
*
* Handles inbound emails via our self-hosted Postal mail server
* and delivery event webhooks.
*
* Postal is our primary production email provider.
*
* @see https://docs.postalserver.io/developer/webhooks
*/
class PostalEmailWebhookController extends Controller
{
public function __construct(
protected InboundEmailService $inboundEmailService,
protected CrmChannelService $crmChannelService,
) {}
/**
* Handle Postal webhook events.
*
* Postal sends JSON webhooks for various events:
* - MessageReceived: inbound email
* - MessageDelivered: delivery confirmation
* - MessageBounced: bounce notification
* - MessageHeld: held for moderation
* - MessageDelayed: delayed delivery
*
* @see https://docs.postalserver.io/developer/webhooks#event-types
*/
public function handle(Request $request): Response
{
// TODO: Verify Postal webhook signature
// $this->verifySignature($request);
$event = $request->input('event');
$payload = $request->input('payload', []);
Log::info('Postal webhook received', [
'event' => $event,
'message_id' => $payload['message']['id'] ?? null,
]);
try {
return match ($event) {
'MessageReceived' => $this->handleInboundEmail($payload),
'MessageDelivered' => $this->handleDeliveryEvent($payload, 'delivered'),
'MessageBounced' => $this->handleDeliveryEvent($payload, 'bounced'),
'MessageHeld' => $this->handleDeliveryEvent($payload, 'held'),
'MessageDelayed' => $this->handleDeliveryEvent($payload, 'delayed'),
default => $this->handleUnknownEvent($event),
};
} catch (\Exception $e) {
Log::error('Postal webhook error: '.$e->getMessage(), [
'event' => $event,
'exception' => $e,
]);
return response('Error logged', 200);
}
}
/**
* Handle inbound email from Postal.
*
* Postal MessageReceived payload structure:
* {
* "message": {
* "id": 12345,
* "token": "abc123",
* "from": "sender@example.com",
* "to": ["recipient@ourdomain.com"],
* "subject": "Email subject",
* "plain_body": "Plain text content",
* "html_body": "<html>...</html>",
* "attachments": [...],
* "headers": {...},
* "received_with_ssl": true
* },
* "base64_encoded": false
* }
*/
protected function handleInboundEmail(array $payload): Response
{
$message = $payload['message'] ?? [];
if (empty($message)) {
Log::warning('Postal: Empty message payload');
return response('OK', 200);
}
Log::info('Postal inbound email received', [
'from' => $message['from'] ?? null,
'to' => $message['to'][0] ?? null,
'subject' => $message['subject'] ?? null,
]);
$normalized = $this->normalizePayload($message, $payload);
$crmMessage = $this->inboundEmailService->handleInbound($normalized);
if ($crmMessage) {
Log::info("Postal: Created CRM message {$crmMessage->id}");
}
return response('OK', 200);
}
/**
* Handle delivery event from Postal.
*/
protected function handleDeliveryEvent(array $payload, string $eventType): Response
{
$message = $payload['message'] ?? [];
$messageId = $message['message_id'] ?? $message['id'] ?? null;
if (! $messageId) {
return response('OK', 200);
}
$status = match ($eventType) {
'delivered' => 'delivered',
'bounced' => 'bounced',
'held' => 'held',
'delayed' => 'pending',
default => null,
};
if ($status) {
$error = $message['bounce_message'] ?? $message['details'] ?? null;
$this->crmChannelService->updateMessageStatus((string) $messageId, $status, $error);
}
return response('OK', 200);
}
/**
* Handle unknown event type.
*/
protected function handleUnknownEvent(?string $event): Response
{
Log::info('Postal: Unhandled event type', ['event' => $event]);
return response('OK', 200);
}
/**
* Normalize Postal payload to our standard format.
*/
protected function normalizePayload(array $message, array $rawPayload): array
{
// Parse from address
$from = $message['from'] ?? '';
$fromEmail = $from;
$fromName = null;
if (preg_match('/^(.+?)\s*<(.+?)>$/', $from, $matches)) {
$fromName = trim($matches[1], '" ');
$fromEmail = $matches[2];
}
// Get first recipient
$to = $message['to'][0] ?? '';
$toEmail = $to;
if (preg_match('/<(.+?)>/', $to, $matches)) {
$toEmail = $matches[1];
}
// Parse headers - Postal provides them as an object
$headers = $message['headers'] ?? [];
$headersLower = [];
foreach ($headers as $key => $value) {
$headersLower[strtolower($key)] = $value;
}
// Handle attachments
$attachments = [];
foreach ($message['attachments'] ?? [] as $att) {
$attachments[] = [
'filename' => $att['filename'] ?? 'attachment',
'content_type' => $att['content_type'] ?? 'application/octet-stream',
'size' => $att['size'] ?? null,
'content_id' => $att['content_id'] ?? null,
'data' => $att['data'] ?? null, // Base64 encoded if present
];
}
return [
'provider' => 'postal',
'from_email' => strtolower(trim($fromEmail)),
'from_name' => $fromName,
'to_email' => strtolower(trim($toEmail)),
'subject' => $message['subject'] ?? null,
'text_body' => $message['plain_body'] ?? null,
'html_body' => $message['html_body'] ?? null,
'message_id' => $headersLower['message-id'] ?? $message['message_id'] ?? (string) ($message['id'] ?? ''),
'in_reply_to' => $headersLower['in-reply-to'] ?? null,
'references' => $headersLower['references'] ?? null,
'headers' => $headersLower,
'attachments' => $attachments,
'postal_id' => $message['id'] ?? null,
'postal_token' => $message['token'] ?? null,
];
}
/**
* Verify Postal webhook signature.
*
* Postal signs webhooks using RSA. The signature is in the X-Postal-Signature header.
*
* @see https://docs.postalserver.io/developer/webhooks#verifying-webhook-signatures
*/
protected function verifySignature(Request $request): void
{
// TODO: Implement signature verification
// $signature = $request->header('X-Postal-Signature');
// $publicKey = config('services.postal.webhook_public_key');
// Verify using openssl_verify()
}
}

View File

@@ -0,0 +1,164 @@
<?php
namespace App\Http\Controllers\Webhook;
use App\Http\Controllers\Controller;
use App\Services\Crm\CrmChannelService;
use App\Services\Email\InboundEmailService;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;
/**
* Postmark Email Webhook Controller
*
* Handles inbound emails via Postmark Inbound
* and delivery event webhooks.
*
* @see https://postmarkapp.com/developer/webhooks/inbound-webhook
* @see https://postmarkapp.com/developer/webhooks/delivery-webhook
*/
class PostmarkEmailWebhookController extends Controller
{
public function __construct(
protected InboundEmailService $inboundEmailService,
protected CrmChannelService $crmChannelService,
) {}
/**
* Handle Postmark Inbound webhook.
*
* Postmark sends JSON with these fields:
* - FromFull: { Email, Name }
* - ToFull: [{ Email, Name }]
* - Subject: email subject
* - TextBody: plain text body
* - HtmlBody: HTML body
* - MessageID: Postmark message ID
* - Headers: [{ Name, Value }]
* - Attachments: [{ Name, Content, ContentType, ContentLength }]
*/
public function inbound(Request $request): Response
{
Log::info('Postmark Inbound webhook received', [
'to' => $request->input('ToFull.0.Email'),
'from' => $request->input('FromFull.Email'),
'subject' => $request->input('Subject'),
]);
try {
$payload = $this->normalizePayload($request);
$message = $this->inboundEmailService->handleInbound($payload);
if ($message) {
Log::info("Postmark: Created CRM message {$message->id}");
}
return response('OK', 200);
} catch (\Exception $e) {
Log::error('Postmark Inbound webhook error: '.$e->getMessage(), [
'exception' => $e,
]);
return response('Error logged', 200);
}
}
/**
* Handle Postmark delivery/bounce webhook.
*/
public function events(Request $request): Response
{
Log::info('Postmark Events webhook received');
try {
$recordType = $request->input('RecordType');
$messageId = $request->input('MessageID');
if ($recordType && $messageId) {
$this->processEvent($request->all());
}
return response('OK', 200);
} catch (\Exception $e) {
Log::error('Postmark Events webhook error: '.$e->getMessage());
return response('Error logged', 200);
}
}
/**
* Normalize Postmark payload to our standard format.
*/
protected function normalizePayload(Request $request): array
{
$fromFull = $request->input('FromFull', []);
$toFull = $request->input('ToFull', []);
$headers = $request->input('Headers', []);
// Convert headers array to associative
$headersAssoc = [];
foreach ($headers as $header) {
$key = strtolower($header['Name'] ?? '');
if ($key) {
$headersAssoc[$key] = $header['Value'] ?? '';
}
}
// Handle attachments
$attachments = [];
foreach ($request->input('Attachments', []) as $att) {
$attachments[] = [
'filename' => $att['Name'] ?? 'attachment',
'content_type' => $att['ContentType'] ?? 'application/octet-stream',
'size' => $att['ContentLength'] ?? null,
'content_id' => $att['ContentID'] ?? null,
];
}
return [
'provider' => 'postmark',
'from_email' => strtolower(trim($fromFull['Email'] ?? $request->input('From', ''))),
'from_name' => $fromFull['Name'] ?? null,
'to_email' => strtolower(trim($toFull[0]['Email'] ?? $request->input('To', ''))),
'subject' => $request->input('Subject'),
'text_body' => $request->input('TextBody'),
'html_body' => $request->input('HtmlBody'),
'message_id' => $headersAssoc['message-id'] ?? $request->input('MessageID'),
'in_reply_to' => $headersAssoc['in-reply-to'] ?? null,
'references' => $headersAssoc['references'] ?? null,
'headers' => $headersAssoc,
'attachments' => $attachments,
];
}
/**
* Process a delivery event.
*/
protected function processEvent(array $event): void
{
$recordType = $event['RecordType'] ?? null;
$messageId = $event['MessageID'] ?? null;
if (! $recordType || ! $messageId) {
return;
}
$status = match ($recordType) {
'Delivery' => 'delivered',
'Open' => 'read',
'Bounce', 'HardBounce', 'SoftBounce' => 'bounced',
'SpamComplaint' => 'failed',
default => null,
};
if ($status) {
$this->crmChannelService->updateMessageStatus(
$messageId,
$status,
$event['Description'] ?? null
);
}
}
}

View File

@@ -0,0 +1,252 @@
<?php
namespace App\Http\Controllers\Webhook;
use App\Http\Controllers\Controller;
use App\Services\Crm\CrmChannelService;
use App\Services\Email\InboundEmailService;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;
/**
* SendGrid Email Webhook Controller
*
* Handles inbound emails via SendGrid Inbound Parse
* and delivery event webhooks.
*
* @see https://docs.sendgrid.com/for-developers/parsing-email/setting-up-the-inbound-parse-webhook
* @see https://docs.sendgrid.com/for-developers/tracking-events/event
*/
class SendgridEmailWebhookController extends Controller
{
public function __construct(
protected InboundEmailService $inboundEmailService,
protected CrmChannelService $crmChannelService,
) {}
/**
* Handle SendGrid Inbound Parse webhook.
*
* SendGrid sends multipart/form-data with these fields:
* - from: sender email (with name)
* - to: recipient email
* - subject: email subject
* - text: plain text body
* - html: HTML body
* - headers: raw headers as text
* - envelope: JSON with actual from/to
* - attachments: number of attachments
* - attachment-info: JSON with attachment metadata
* - attachment1, attachment2, etc: actual attachment files
*/
public function inbound(Request $request): Response
{
Log::info('SendGrid Inbound Parse webhook received', [
'to' => $request->input('to'),
'from' => $request->input('from'),
'subject' => $request->input('subject'),
]);
// TODO: Verify SendGrid webhook signature
// $this->verifySignature($request);
try {
$payload = $this->normalizePayload($request);
$message = $this->inboundEmailService->handleInbound($payload);
if ($message) {
Log::info("SendGrid: Created CRM message {$message->id}");
}
return response('OK', 200);
} catch (\Exception $e) {
Log::error('SendGrid Inbound Parse error: '.$e->getMessage(), [
'exception' => $e,
]);
// Still return 200 to prevent retries for application errors
return response('Error logged', 200);
}
}
/**
* Handle SendGrid delivery event webhook.
*
* Events include: delivered, bounce, dropped, deferred, etc.
*/
public function events(Request $request): Response
{
Log::info('SendGrid Events webhook received');
// TODO: Verify SendGrid event webhook signature
try {
$events = $request->json()->all();
// SendGrid sends events as an array
if (! is_array($events)) {
$events = [$events];
}
foreach ($events as $event) {
$this->processEvent($event);
}
return response('OK', 200);
} catch (\Exception $e) {
Log::error('SendGrid Events webhook error: '.$e->getMessage());
return response('Error logged', 200);
}
}
/**
* Normalize SendGrid Inbound Parse payload to our standard format.
*/
protected function normalizePayload(Request $request): array
{
// Parse from email (format: "Name <email@example.com>" or just "email@example.com")
$from = $request->input('from', '');
$fromEmail = $from;
$fromName = null;
if (preg_match('/^(.+?)\s*<(.+?)>$/', $from, $matches)) {
$fromName = trim($matches[1], '" ');
$fromEmail = $matches[2];
}
// Parse to email (can be multiple, take first)
$to = $request->input('to', '');
$toEmail = $to;
if (preg_match('/<(.+?)>/', $to, $matches)) {
$toEmail = $matches[1];
} elseif (strpos($to, ',') !== false) {
$toEmail = trim(explode(',', $to)[0]);
}
// Parse headers
$rawHeaders = $request->input('headers', '');
$headers = $this->parseHeaders($rawHeaders);
// Get envelope data for more accurate addresses
$envelope = json_decode($request->input('envelope', '{}'), true);
if (! empty($envelope['from'])) {
$fromEmail = $envelope['from'];
}
if (! empty($envelope['to'][0])) {
$toEmail = $envelope['to'][0];
}
// Handle attachments
$attachments = [];
$attachmentInfo = json_decode($request->input('attachment-info', '{}'), true);
foreach ($attachmentInfo as $key => $info) {
$attachments[] = [
'filename' => $info['filename'] ?? $key,
'content_type' => $info['type'] ?? 'application/octet-stream',
'size' => $info['size'] ?? null,
'content_id' => $info['content-id'] ?? null,
];
}
return [
'provider' => 'sendgrid',
'from_email' => strtolower(trim($fromEmail)),
'from_name' => $fromName,
'to_email' => strtolower(trim($toEmail)),
'subject' => $request->input('subject'),
'text_body' => $request->input('text'),
'html_body' => $request->input('html'),
'message_id' => $headers['message-id'] ?? null,
'in_reply_to' => $headers['in-reply-to'] ?? null,
'references' => $headers['references'] ?? null,
'headers' => $headers,
'attachments' => $attachments,
];
}
/**
* Parse raw email headers into associative array.
*/
protected function parseHeaders(string $rawHeaders): array
{
$headers = [];
$lines = explode("\n", $rawHeaders);
$currentKey = null;
$currentValue = '';
foreach ($lines as $line) {
// Continuation of previous header
if (preg_match('/^\s+(.*)/', $line, $matches)) {
$currentValue .= ' '.trim($matches[1]);
continue;
}
// Save previous header
if ($currentKey !== null) {
$headers[strtolower($currentKey)] = trim($currentValue);
}
// Parse new header
if (preg_match('/^([^:]+):\s*(.*)/', $line, $matches)) {
$currentKey = $matches[1];
$currentValue = $matches[2];
}
}
// Save last header
if ($currentKey !== null) {
$headers[strtolower($currentKey)] = trim($currentValue);
}
return $headers;
}
/**
* Process a delivery event.
*/
protected function processEvent(array $event): void
{
$eventType = $event['event'] ?? null;
$messageId = $event['sg_message_id'] ?? $event['smtp-id'] ?? null;
if (! $eventType || ! $messageId) {
return;
}
$status = match ($eventType) {
'delivered' => 'delivered',
'open' => 'read',
'bounce', 'dropped' => 'bounced',
'deferred', 'spamreport' => 'failed',
default => null,
};
if ($status) {
$this->crmChannelService->updateMessageStatus(
$messageId,
$status,
$event['reason'] ?? null
);
}
}
/**
* Verify SendGrid webhook signature.
*
* @see https://docs.sendgrid.com/for-developers/tracking-events/getting-started-event-webhook-security-features
*/
protected function verifySignature(Request $request): void
{
// TODO: Implement signature verification
// $signature = $request->header('X-Twilio-Email-Event-Webhook-Signature');
// $timestamp = $request->header('X-Twilio-Email-Event-Webhook-Timestamp');
// Verify using public key
}
}

View File

@@ -0,0 +1,237 @@
<?php
namespace App\Http\Controllers\Webhook;
use App\Http\Controllers\Controller;
use App\Services\Crm\CrmChannelService;
use App\Services\Email\InboundEmailService;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;
/**
* AWS SES Email Webhook Controller
*
* Handles inbound emails via SES SNS HTTP
* and delivery notification webhooks.
*
* @see https://docs.aws.amazon.com/ses/latest/dg/receiving-email-notifications-contents.html
* @see https://docs.aws.amazon.com/ses/latest/dg/notification-contents.html
*/
class SesEmailWebhookController extends Controller
{
public function __construct(
protected InboundEmailService $inboundEmailService,
protected CrmChannelService $crmChannelService,
) {}
/**
* Handle AWS SES webhook via SNS.
*
* SNS sends JSON with:
* - Type: 'SubscriptionConfirmation' | 'Notification' | 'UnsubscribeConfirmation'
* - Message: JSON string containing the actual SES notification
* - SubscribeURL: URL to confirm subscription (for SubscriptionConfirmation)
*/
public function handle(Request $request): Response
{
// Handle SNS subscription confirmation
if ($request->input('Type') === 'SubscriptionConfirmation') {
return $this->handleSubscriptionConfirmation($request);
}
// Handle unsubscribe confirmation
if ($request->input('Type') === 'UnsubscribeConfirmation') {
Log::info('SES SNS unsubscribe confirmation received');
return response('OK', 200);
}
// Handle notifications
if ($request->input('Type') === 'Notification') {
return $this->handleNotification($request);
}
Log::warning('SES webhook: Unknown Type', ['type' => $request->input('Type')]);
return response('Unknown type', 200);
}
/**
* Handle SNS subscription confirmation.
*/
protected function handleSubscriptionConfirmation(Request $request): Response
{
$subscribeUrl = $request->input('SubscribeURL');
if ($subscribeUrl) {
Log::info('SES SNS subscription confirmation', ['url' => $subscribeUrl]);
// Auto-confirm subscription
try {
file_get_contents($subscribeUrl);
Log::info('SES SNS subscription confirmed');
} catch (\Exception $e) {
Log::error('SES SNS subscription confirmation failed: '.$e->getMessage());
}
}
return response('OK', 200);
}
/**
* Handle SNS notification.
*/
protected function handleNotification(Request $request): Response
{
$messageJson = $request->input('Message');
if (! $messageJson) {
Log::warning('SES webhook: No Message in notification');
return response('OK', 200);
}
try {
$message = json_decode($messageJson, true);
if (! $message) {
Log::warning('SES webhook: Invalid JSON in Message');
return response('OK', 200);
}
// Determine notification type
$notificationType = $message['notificationType'] ?? $message['eventType'] ?? null;
if ($notificationType === 'Received') {
return $this->handleInboundEmail($message);
}
// Handle delivery notifications
if (in_array($notificationType, ['Delivery', 'Bounce', 'Complaint', 'Open'])) {
return $this->handleDeliveryEvent($message);
}
Log::info('SES webhook: Unhandled notification type', ['type' => $notificationType]);
return response('OK', 200);
} catch (\Exception $e) {
Log::error('SES webhook error: '.$e->getMessage(), [
'exception' => $e,
]);
return response('Error logged', 200);
}
}
/**
* Handle inbound email notification.
*/
protected function handleInboundEmail(array $message): Response
{
Log::info('SES Inbound email received');
$mail = $message['mail'] ?? [];
$receipt = $message['receipt'] ?? [];
$content = $message['content'] ?? null;
// For full email content, SES needs to store in S3 and we fetch it
// For now, we work with the basic metadata
$payload = $this->normalizePayload($mail, $receipt, $content);
$crmMessage = $this->inboundEmailService->handleInbound($payload);
if ($crmMessage) {
Log::info("SES: Created CRM message {$crmMessage->id}");
}
return response('OK', 200);
}
/**
* Handle delivery event notification.
*/
protected function handleDeliveryEvent(array $message): Response
{
$notificationType = $message['notificationType'] ?? $message['eventType'] ?? null;
$mail = $message['mail'] ?? [];
$messageId = $mail['messageId'] ?? null;
if (! $messageId) {
return response('OK', 200);
}
$status = match ($notificationType) {
'Delivery' => 'delivered',
'Open' => 'read',
'Bounce' => 'bounced',
'Complaint' => 'failed',
default => null,
};
if ($status) {
$bounce = $message['bounce'] ?? [];
$complaint = $message['complaint'] ?? [];
$error = $bounce['bounceType'] ?? $complaint['complaintFeedbackType'] ?? null;
$this->crmChannelService->updateMessageStatus($messageId, $status, $error);
}
return response('OK', 200);
}
/**
* Normalize SES payload to our standard format.
*/
protected function normalizePayload(array $mail, array $receipt, ?string $content): array
{
$source = $mail['source'] ?? '';
$destination = $mail['destination'][0] ?? $receipt['recipients'][0] ?? '';
// Parse headers
$headers = [];
foreach ($mail['headers'] ?? [] as $header) {
$headers[strtolower($header['name'])] = $header['value'];
}
// Parse from address
$fromEmail = $source;
$fromName = null;
if (preg_match('/^(.+?)\s*<(.+?)>$/', $source, $matches)) {
$fromName = trim($matches[1], '" ');
$fromEmail = $matches[2];
}
// If we have raw content, parse it
$textBody = null;
$htmlBody = null;
if ($content) {
// Basic email parsing (for production, use a proper email parser)
if (preg_match('/Content-Type:\s*text\/plain.*?\r?\n\r?\n(.+?)(?=\r?\n--|\Z)/s', $content, $matches)) {
$textBody = trim($matches[1]);
}
if (preg_match('/Content-Type:\s*text\/html.*?\r?\n\r?\n(.+?)(?=\r?\n--|\Z)/s', $content, $matches)) {
$htmlBody = trim($matches[1]);
}
}
return [
'provider' => 'ses',
'from_email' => strtolower(trim($fromEmail)),
'from_name' => $fromName,
'to_email' => strtolower(trim($destination)),
'subject' => $headers['subject'] ?? $mail['commonHeaders']['subject'] ?? null,
'text_body' => $textBody,
'html_body' => $htmlBody,
'message_id' => $headers['message-id'] ?? $mail['messageId'] ?? null,
'in_reply_to' => $headers['in-reply-to'] ?? null,
'references' => $headers['references'] ?? null,
'headers' => $headers,
'attachments' => [], // Would need to fetch from S3 for attachments
];
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace App\Http\Middleware;
use App\Models\Business;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Middleware to ensure the user has Marketing Portal access.
*
* Marketing Portal access is granted to users with:
* - contact_type = 'marketing_portal' on business_user pivot
* - OR users who are super admins (for testing/support)
*
* This middleware is applied to /portal/{business}/* routes.
*/
class EnsureMarketingPortalAccess
{
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
// Must be authenticated
if (! $user) {
return redirect()->route('login');
}
// Get business from route (could be slug or id)
$businessParam = $request->route('business');
$business = $this->resolveBusiness($businessParam);
if (! $business) {
abort(404, 'Business not found.');
}
// Store resolved business for later use
$request->route()->setParameter('business', $business);
// Super admins can access any portal
if ($user->isSuperAdmin()) {
$request->attributes->set('is_portal_admin', true);
return $next($request);
}
// Check if user has marketing portal access for this business
if (! $user->isMarketingPortalUser($business)) {
abort(403, 'You do not have Marketing Portal access for this business.');
}
$request->attributes->set('is_portal_admin', false);
return $next($request);
}
/**
* Resolve business from slug or ID
*/
protected function resolveBusiness($param): ?Business
{
if (! $param) {
return null;
}
// Already a Business model
if ($param instanceof Business) {
return $param;
}
// Try by slug first, then by ID
return Business::where('slug', $param)->first()
?? Business::find($param);
}
}

View File

@@ -29,9 +29,9 @@ class StoreBrandRequest extends FormRequest
return [
'name' => 'required|string|max:255',
'tagline' => ['nullable', 'string', 'min:30', 'max:45'],
'description' => ['nullable', 'string', 'min:100', 'max:150'],
'long_description' => ['nullable', 'string', 'max:500'],
'tagline' => ['nullable', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:1000'],
'long_description' => ['nullable', 'string', 'max:5000'],
'brand_announcement' => ['nullable', 'string', 'max:500'],
'website_url' => 'nullable|string|max:255',

View File

@@ -30,9 +30,9 @@ class UpdateBrandRequest extends FormRequest
return [
'name' => 'required|string|max:255',
'tagline' => ['nullable', 'string', 'min:30', 'max:45'],
'description' => ['nullable', 'string', 'min:100', 'max:150'],
'long_description' => ['nullable', 'string', 'max:500'],
'tagline' => ['nullable', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:1000'],
'long_description' => ['nullable', 'string', 'max:5000'],
'brand_announcement' => ['nullable', 'string', 'max:500'],
'website_url' => 'nullable|string|max:255',
@@ -75,6 +75,9 @@ class UpdateBrandRequest extends FormRequest
'support_email' => 'nullable|email|max:255',
'wholesale_email' => 'nullable|email|max:255',
'pr_email' => 'nullable|email|max:255',
// CRM Channel Assignment
'inbound_email_channel_id' => 'nullable|integer|exists:crm_channels,id',
];
}
}

View File

@@ -0,0 +1,159 @@
<?php
namespace App\Jobs;
use App\Models\Brand;
use App\Models\Business;
use App\Services\Cannaiq\BrandAnalysisService;
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;
/**
* Background job to pre-calculate Brand Analysis metrics.
*
* This job runs in the background to compute expensive engagement and sentiment
* metrics for brands, caching the results for 2 hours. This prevents N+1 queries
* and expensive aggregations from running on page load.
*
* Schedule: Every 2 hours via Horizon
* Queue: default (or 'analytics' if available)
*
* Key benefits:
* - Aggregates CRM message counts, response rates, and quote/order metrics in batch
* - Pre-computes buyer engagement scores
* - For CannaiQ-enabled businesses, also pre-computes sentiment scores
* - Uses existing BrandAnalysisService caching mechanism (2-hour TTL)
*/
class CalculateBrandAnalysisMetrics implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* The business to calculate metrics for (null = all seller businesses)
*/
public ?int $businessId;
/**
* The brand to calculate metrics for (null = all brands in business)
*/
public ?int $brandId;
/**
* Create a new job instance.
*/
public function __construct(?int $businessId = null, ?int $brandId = null)
{
$this->businessId = $businessId;
$this->brandId = $brandId;
}
/**
* Execute the job.
*/
public function handle(BrandAnalysisService $service): void
{
$startTime = microtime(true);
$processedCount = 0;
try {
if ($this->businessId && $this->brandId) {
// Single brand calculation
$this->calculateForBrand($service, $this->businessId, $this->brandId);
$processedCount = 1;
} elseif ($this->businessId) {
// All brands for a single business
$processedCount = $this->calculateForBusiness($service, $this->businessId);
} else {
// All seller businesses with active brands
$processedCount = $this->calculateForAllBusinesses($service);
}
$duration = round(microtime(true) - $startTime, 2);
Log::info('CalculateBrandAnalysisMetrics completed', [
'business_id' => $this->businessId ?? 'all',
'brand_id' => $this->brandId ?? 'all',
'brands_processed' => $processedCount,
'duration_seconds' => $duration,
]);
} catch (\Exception $e) {
Log::error('CalculateBrandAnalysisMetrics failed', [
'business_id' => $this->businessId,
'brand_id' => $this->brandId,
'error' => $e->getMessage(),
]);
throw $e;
}
}
/**
* Calculate metrics for all seller businesses
*/
private function calculateForAllBusinesses(BrandAnalysisService $service): int
{
$processedCount = 0;
Business::where('type', 'seller')
->where('status', 'approved')
->chunk(10, function ($businesses) use ($service, &$processedCount) {
foreach ($businesses as $business) {
$processedCount += $this->calculateForBusiness($service, $business->id);
}
});
return $processedCount;
}
/**
* Calculate metrics for all active brands in a business
*/
private function calculateForBusiness(BrandAnalysisService $service, int $businessId): int
{
$business = Business::find($businessId);
if (! $business) {
return 0;
}
$brands = Brand::where('business_id', $businessId)
->where('is_active', true)
->get();
foreach ($brands as $brand) {
$this->calculateForBrand($service, $businessId, $brand->id);
}
return $brands->count();
}
/**
* Calculate metrics for a single brand
*/
private function calculateForBrand(BrandAnalysisService $service, int $businessId, int $brandId): void
{
$business = Business::find($businessId);
$brand = Brand::find($brandId);
if (! $business || ! $brand) {
return;
}
// This triggers the full analysis calculation and caches it
// The BrandAnalysisService handles caching internally with 2-hour TTL
$service->refreshAnalysis($brand, $business);
}
/**
* The job failed to process.
*/
public function failed(\Throwable $exception): void
{
Log::error('CalculateBrandAnalysisMetrics job failed', [
'business_id' => $this->businessId,
'brand_id' => $this->brandId,
'exception' => $exception->getMessage(),
]);
}
}

View File

@@ -0,0 +1,117 @@
<?php
namespace App\Jobs;
use App\Models\Marketing\MarketingAutomation;
use App\Services\Marketing\AutomationRunner;
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 RunMarketingAutomationJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* The number of times the job may be attempted.
*/
public int $tries = 3;
/**
* The number of seconds to wait before retrying the job.
*/
public int $backoff = 60;
/**
* The automation ID to run.
*/
public int $automationId;
/**
* Create a new job instance.
*/
public function __construct(int $automationId)
{
$this->automationId = $automationId;
$this->onQueue('marketing');
}
/**
* Execute the job.
*/
public function handle(AutomationRunner $runner): void
{
$automation = MarketingAutomation::find($this->automationId);
if (! $automation) {
Log::warning('RunMarketingAutomationJob: Automation not found', [
'automation_id' => $this->automationId,
]);
return;
}
if (! $automation->is_active) {
Log::info('RunMarketingAutomationJob: Automation is inactive, skipping', [
'automation_id' => $this->automationId,
'automation_name' => $automation->name,
]);
return;
}
Log::info('RunMarketingAutomationJob: Starting automation run', [
'automation_id' => $automation->id,
'automation_name' => $automation->name,
'business_id' => $automation->business_id,
]);
$run = $runner->runAutomation($automation);
Log::info('RunMarketingAutomationJob: Automation run completed', [
'automation_id' => $automation->id,
'run_id' => $run->id,
'status' => $run->status,
'summary' => $run->summary,
]);
}
/**
* Handle a job failure.
*/
public function failed(\Throwable $exception): void
{
Log::error('RunMarketingAutomationJob: Job failed', [
'automation_id' => $this->automationId,
'error' => $exception->getMessage(),
'trace' => $exception->getTraceAsString(),
]);
// Update automation status to error
$automation = MarketingAutomation::find($this->automationId);
if ($automation) {
$automation->update([
'last_run_at' => now(),
'last_status' => 'error',
]);
}
}
/**
* Get the tags that should be assigned to the job.
*/
public function tags(): array
{
$automation = MarketingAutomation::find($this->automationId);
return [
'marketing',
'automation',
'automation:'.$this->automationId,
'business:'.($automation?->business_id ?? 'unknown'),
];
}
}

View File

@@ -0,0 +1,131 @@
<?php
namespace App\Jobs;
use App\Models\Marketing\MarketingCampaign;
use App\Services\Messaging\EmailSender;
use App\Services\Messaging\SmsSender;
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;
/**
* SendMarketingCampaignJob - Process and send a marketing campaign.
*
* Handles email and/or SMS sending based on campaign channel.
* Chunks through contacts for memory efficiency.
*/
class SendMarketingCampaignJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 3;
public int $timeout = 3600; // 1 hour max
public function __construct(
protected int $campaignId
) {}
public function handle(EmailSender $emailSender, SmsSender $smsSender): void
{
$campaign = MarketingCampaign::find($this->campaignId);
if (! $campaign) {
Log::channel('marketing')->error('Campaign not found', ['campaign_id' => $this->campaignId]);
return;
}
if (! in_array($campaign->status, [MarketingCampaign::STATUS_DRAFT, MarketingCampaign::STATUS_SCHEDULED])) {
Log::channel('marketing')->warning('Campaign not in sendable state', [
'campaign_id' => $campaign->id,
'status' => $campaign->status,
]);
return;
}
if (! $campaign->list) {
Log::channel('marketing')->error('Campaign has no list', ['campaign_id' => $campaign->id]);
$campaign->cancel();
return;
}
Log::channel('marketing')->info('Starting campaign send', [
'campaign_id' => $campaign->id,
'campaign_name' => $campaign->name,
'channel' => $campaign->channel,
]);
$campaign->markSending();
$sendEmail = in_array($campaign->channel, [MarketingCampaign::CHANNEL_EMAIL, MarketingCampaign::CHANNEL_MULTI]);
$sendSms = in_array($campaign->channel, [MarketingCampaign::CHANNEL_SMS, MarketingCampaign::CHANNEL_MULTI]);
$totalSent = 0;
$totalFailed = 0;
// Process contacts in chunks
$campaign->list->getContacts()
->chunk(100, function ($contacts) use ($campaign, $emailSender, $smsSender, $sendEmail, $sendSms, &$totalSent, &$totalFailed) {
foreach ($contacts as $contact) {
if ($sendEmail && $contact->canReceiveEmail()) {
$log = $emailSender->sendCampaignEmail($campaign, $contact);
if ($log->status === 'sent') {
$totalSent++;
} else {
$totalFailed++;
}
}
if ($sendSms && $contact->canReceiveSms()) {
$log = $smsSender->sendCampaignSms($campaign, $contact);
if ($log->status === 'sent') {
$totalSent++;
} else {
$totalFailed++;
}
}
// Small delay to prevent rate limiting
usleep(50000); // 50ms
}
});
$campaign->markSent();
$campaign->updateMetrics();
Log::channel('marketing')->info('Campaign send completed', [
'campaign_id' => $campaign->id,
'total_sent' => $totalSent,
'total_failed' => $totalFailed,
]);
}
public function failed(\Throwable $exception): void
{
Log::channel('marketing')->error('Campaign job failed', [
'campaign_id' => $this->campaignId,
'error' => $exception->getMessage(),
]);
$campaign = MarketingCampaign::find($this->campaignId);
if ($campaign && $campaign->status === MarketingCampaign::STATUS_SENDING) {
// Don't cancel - leave as sending so admin can investigate
$campaign->update([
'metrics' => array_merge($campaign->metrics ?? [], [
'error' => $exception->getMessage(),
'failed_at' => now()->toIso8601String(),
]),
]);
}
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Mail\Invoices;
use App\Models\Invoice;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Attachment;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class InvoiceSentMail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public Invoice $invoice,
public ?string $message = null,
public ?string $pdfContent = null
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: "Invoice {$this->invoice->invoice_number} from ".config('app.name'),
);
}
public function content(): Content
{
return new Content(
markdown: 'emails.invoices.invoice-sent',
);
}
public function attachments(): array
{
if (! $this->pdfContent) {
return [];
}
return [
Attachment::fromData(
fn () => $this->pdfContent,
"{$this->invoice->invoice_number}.pdf"
)->withMime('application/pdf'),
];
}
}

60
app/Mail/QuoteMail.php Normal file
View File

@@ -0,0 +1,60 @@
<?php
namespace App\Mail;
use App\Models\Business;
use App\Models\Crm\CrmQuote;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Attachment;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Storage;
class QuoteMail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public CrmQuote $quote,
public Business $business,
public ?string $customMessage = null,
public ?string $pdfPath = null
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: "Quote {$this->quote->quote_number} from {$this->business->name}",
);
}
public function content(): Content
{
return new Content(
view: 'emails.quote',
with: [
'quote' => $this->quote,
'business' => $this->business,
'customMessage' => $this->customMessage,
],
);
}
/**
* @return array<int, Attachment>
*/
public function attachments(): array
{
if (! $this->pdfPath || ! Storage::exists($this->pdfPath)) {
return [];
}
return [
Attachment::fromStorage($this->pdfPath)
->as("{$this->quote->quote_number}.pdf")
->withMime('application/pdf'),
];
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Models;
use App\Traits\BelongsToBusinessViaProduct;
use App\Traits\HasHashid;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -13,15 +14,17 @@ use Illuminate\Support\Facades\Storage;
class Batch extends Model
{
use BelongsToBusinessViaProduct, HasFactory, SoftDeletes;
use BelongsToBusinessViaProduct, HasFactory, HasHashid, SoftDeletes;
protected $fillable = [
'hashid',
'product_id',
'lab_id',
'parent_batch_id',
'business_id',
'cannabinoid_unit',
'batch_number',
'quantity_unit',
'internal_code',
'batch_type',
'production_date',

View File

@@ -126,6 +126,9 @@ class Brand extends Model implements Auditable
'support_email',
'wholesale_email',
'pr_email',
// CRM Channel for inbound emails
'inbound_email_channel_id',
];
protected $casts = [
@@ -201,6 +204,14 @@ class Brand extends Model implements Auditable
return $this->hasOne(BrandAiProfile::class);
}
/**
* The CRM channel used for inbound emails to this brand.
*/
public function inboundEmailChannel(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(\App\Models\Crm\CrmChannel::class, 'inbound_email_channel_id');
}
/**
* Check if this brand has an AI profile configured
*/

View File

@@ -0,0 +1,169 @@
<?php
namespace App\Models\Branding;
use App\Models\Business;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Storage;
/**
* Business Branding Settings
*
* White-label branding configuration for the Marketing Portal.
* Controls logo, colors, and messaging defaults for portal users.
*/
class BusinessBrandingSetting extends Model
{
protected $fillable = [
'business_id',
'logo_path',
'favicon_path',
'primary_color',
'secondary_color',
'accent_color',
'email_from_name',
'email_from_email',
'sms_from_label',
'portal_title',
'portal_welcome_message',
'meta',
];
protected $casts = [
'meta' => 'array',
];
// -------------------------------------------------------------------------
// Relationships
// -------------------------------------------------------------------------
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
// -------------------------------------------------------------------------
// Accessors
// -------------------------------------------------------------------------
/**
* Get the logo URL for display
*/
public function getLogoUrlAttribute(): ?string
{
if (! $this->logo_path) {
return null;
}
return Storage::url($this->logo_path);
}
/**
* Get the favicon URL for display
*/
public function getFaviconUrlAttribute(): ?string
{
if (! $this->favicon_path) {
return null;
}
return Storage::url($this->favicon_path);
}
/**
* Get effective email from name (falls back to business name)
*/
public function getEffectiveFromNameAttribute(): string
{
return $this->email_from_name ?? $this->business->name ?? 'Marketing Portal';
}
/**
* Get effective email from address
*/
public function getEffectiveFromEmailAttribute(): ?string
{
return $this->email_from_email;
}
/**
* Get effective SMS sender label
*/
public function getEffectiveSmsLabelAttribute(): string
{
// SMS sender IDs have strict requirements - max 11 alphanumeric chars
$label = $this->sms_from_label ?? substr(preg_replace('/[^A-Za-z0-9]/', '', $this->business->name ?? 'Portal'), 0, 11);
return $label ?: 'Portal';
}
/**
* Get portal title (falls back to business name + " Portal")
*/
public function getEffectivePortalTitleAttribute(): string
{
return $this->portal_title ?? ($this->business->name ?? 'Marketing').' Portal';
}
// -------------------------------------------------------------------------
// CSS Helper Methods
// -------------------------------------------------------------------------
/**
* Get CSS custom properties for theming
*/
public function getCssVariables(): array
{
$vars = [];
if ($this->primary_color) {
$vars['--portal-primary'] = $this->primary_color;
}
if ($this->secondary_color) {
$vars['--portal-secondary'] = $this->secondary_color;
}
if ($this->accent_color) {
$vars['--portal-accent'] = $this->accent_color;
}
return $vars;
}
/**
* Get inline style string for CSS variables
*/
public function getCssVariableStyle(): string
{
$vars = $this->getCssVariables();
if (empty($vars)) {
return '';
}
return implode('; ', array_map(
fn ($key, $value) => "{$key}: {$value}",
array_keys($vars),
array_values($vars)
));
}
// -------------------------------------------------------------------------
// Static Helpers
// -------------------------------------------------------------------------
/**
* Get or create branding settings for a business
*/
public static function forBusiness(Business $business): self
{
return self::firstOrCreate(
['business_id' => $business->id],
[
'portal_title' => $business->name.' Portal',
]
);
}
}

View File

@@ -292,6 +292,7 @@ class Business extends Model implements AuditableContract
'has_management_suite',
'has_enterprise_suite',
'use_suite_navigation',
'cannaiq_enabled',
// Sales Suite Usage Limits
'sales_suite_brand_limit',
@@ -366,6 +367,7 @@ class Business extends Model implements AuditableContract
'has_enterprise_suite' => 'boolean', // Legacy - use is_enterprise_plan instead
'is_enterprise_plan' => 'boolean', // Plan limit override - when true, usage limits are not enforced
'use_suite_navigation' => 'boolean',
'cannaiq_enabled' => 'boolean',
// Sales Suite Usage Limits
'sales_suite_brand_limit' => 'integer',
'sales_suite_sku_limit_per_brand' => 'integer',
@@ -900,6 +902,44 @@ class Business extends Model implements AuditableContract
return $this->hasSuite('dispensary');
}
/**
* Check if business has CannaiQ enabled.
* CannaiQ provides AI-powered market intelligence, promo recommendations, and automation.
*/
public function hasCannaiqEnabled(): bool
{
return (bool) $this->cannaiq_enabled;
}
/**
* Check if business has Messaging Suite access.
* Messaging is included in Sales Suite or can be standalone.
*/
public function hasMessagingSuite(): bool
{
return $this->hasSuite('messaging') || $this->hasSalesSuite();
}
/**
* Check if business has CannaiQ Marketing Access.
* Required for: Market Intelligence, Promo Builder (AI features)
* Requires BOTH Sales Suite AND CannaiQ enabled.
*/
public function hasCannaiqMarketingAccess(): bool
{
return $this->hasSalesSuite() && $this->hasCannaiqEnabled();
}
/**
* Check if business has Automation Access.
* Required for: Marketing Automations / Playbooks
* Requires Sales Suite + CannaiQ + Messaging (automations send campaigns).
*/
public function hasAutomationAccess(): bool
{
return $this->hasCannaiqMarketingAccess() && $this->hasMessagingSuite();
}
/**
* Check if business has access to a specific feature via any assigned suite.
*

View File

@@ -0,0 +1,206 @@
<?php
namespace App\Models;
use App\Models\Crm\CrmChannel;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* BusinessEmailIdentity - Maps inbound email addresses to businesses/channels
*
* This model represents an email identity that can receive inbound emails.
* It maps the receiving email address to a business, CRM channel, and optionally
* to the BusinessMailSettings for outbound replies.
*
* NOTE: This does NOT store SMTP credentials. Outbound email uses BusinessMailSettings.
*/
class BusinessEmailIdentity extends Model
{
use HasFactory;
public const PROVIDER_POSTAL = 'postal';
public const PROVIDER_SENDGRID = 'sendgrid';
public const PROVIDER_POSTMARK = 'postmark';
public const PROVIDER_SES = 'ses';
public const PROVIDERS = [
self::PROVIDER_POSTAL => 'Postal',
self::PROVIDER_SENDGRID => 'SendGrid',
self::PROVIDER_POSTMARK => 'Postmark',
self::PROVIDER_SES => 'Amazon SES',
];
public const DEPARTMENT_SALES = 'sales';
public const DEPARTMENT_SUPPORT = 'support';
public const DEPARTMENT_ORDERS = 'orders';
public const DEPARTMENT_GENERAL = 'general';
public const DEPARTMENTS = [
self::DEPARTMENT_SALES => 'Sales',
self::DEPARTMENT_SUPPORT => 'Support',
self::DEPARTMENT_ORDERS => 'Orders',
self::DEPARTMENT_GENERAL => 'General',
];
protected $fillable = [
'business_id',
'crm_channel_id',
'mail_settings_id',
'email',
'provider',
'department',
'config',
'is_active',
'is_primary',
'last_received_at',
];
protected $casts = [
'config' => 'array',
'is_active' => 'boolean',
'is_primary' => 'boolean',
'last_received_at' => 'datetime',
];
// Relationships
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function crmChannel(): BelongsTo
{
return $this->belongsTo(CrmChannel::class, 'crm_channel_id');
}
public function mailSettings(): BelongsTo
{
return $this->belongsTo(BusinessMailSettings::class, 'mail_settings_id');
}
// Scopes
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopeForBusiness($query, int $businessId)
{
return $query->where('business_id', $businessId);
}
public function scopeForEmail($query, string $email)
{
return $query->where('email', strtolower($email));
}
public function scopeForProvider($query, string $provider)
{
return $query->where('provider', $provider);
}
public function scopeForDepartment($query, string $department)
{
return $query->where('department', $department);
}
// Static Helpers
/**
* Find an email identity by email address.
*/
public static function findByEmail(string $email): ?self
{
return self::active()
->forEmail(strtolower($email))
->first();
}
/**
* Get or create a CRM channel for this identity.
*/
public function getOrCreateChannel(): CrmChannel
{
if ($this->crmChannel) {
return $this->crmChannel;
}
$channel = CrmChannel::updateOrCreate(
[
'business_id' => $this->business_id,
'type' => CrmChannel::TYPE_EMAIL,
'identifier' => $this->email,
],
[
'name' => $this->department ? ucfirst($this->department).' Email' : 'Email',
'is_active' => true,
'can_send' => true,
'can_receive' => true,
'config' => [
'identity_id' => $this->id,
'provider' => $this->provider,
'department' => $this->department,
],
]
);
$this->update(['crm_channel_id' => $channel->id]);
return $channel;
}
// Helpers
public function getProviderLabel(): string
{
return self::PROVIDERS[$this->provider] ?? ucfirst($this->provider);
}
public function getDepartmentLabel(): string
{
return self::DEPARTMENTS[$this->department] ?? ucfirst($this->department ?? 'General');
}
/**
* Check if this identity uses Postal mail server.
*
* Checks both the identity's own provider field and the linked mail settings.
*/
public function isPostal(): bool
{
// Check identity's own provider
if ($this->provider === self::PROVIDER_POSTAL) {
return true;
}
// Check linked mail settings provider
if ($this->mailSettings && $this->mailSettings->isPostal()) {
return true;
}
return false;
}
public function recordReceived(): void
{
$this->update(['last_received_at' => now()]);
}
/**
* Get the webhook secret for signature verification.
*/
public function getWebhookSecret(): ?string
{
return $this->config['webhook_secret'] ?? null;
}
}

View File

@@ -39,6 +39,16 @@ class BusinessMailSettings extends Model
self::DRIVER_RESEND => 'Resend',
];
// Mail server providers (distinct from driver/transport)
public const PROVIDER_GENERIC_SMTP = 'generic_smtp';
public const PROVIDER_POSTAL = 'postal';
public const PROVIDERS = [
self::PROVIDER_GENERIC_SMTP => 'Standard SMTP',
self::PROVIDER_POSTAL => 'Postal Server',
];
// Common encryption types
public const ENCRYPTION_TLS = 'tls';
@@ -68,6 +78,8 @@ class BusinessMailSettings extends Model
protected $fillable = [
'business_id',
'driver',
'provider',
'provider_config',
'host',
'port',
'encryption',
@@ -87,6 +99,7 @@ class BusinessMailSettings extends Model
'is_active' => 'boolean',
'is_verified' => 'boolean',
'last_tested_at' => 'datetime',
'provider_config' => 'array',
];
protected $hidden = [
@@ -149,6 +162,7 @@ class BusinessMailSettings extends Model
['business_id' => $business->id],
[
'driver' => self::DRIVER_SMTP,
'provider' => self::PROVIDER_GENERIC_SMTP,
'port' => self::PORT_SMTP_TLS,
'encryption' => self::ENCRYPTION_TLS,
'is_active' => false,
@@ -168,6 +182,30 @@ class BusinessMailSettings extends Model
return self::DRIVERS[$this->driver] ?? $this->driver;
}
/**
* Get the provider label.
*/
public function getProviderLabel(): string
{
return self::PROVIDERS[$this->provider] ?? ucfirst($this->provider ?? 'SMTP');
}
/**
* Check if this settings uses Postal mail server.
*/
public function isPostal(): bool
{
return $this->provider === self::PROVIDER_POSTAL;
}
/**
* Get a provider-specific config value.
*/
public function getProviderConfig(string $key, mixed $default = null): mixed
{
return $this->provider_config[$key] ?? $default;
}
/**
* Check if settings are complete enough to attempt sending.
*/

View File

@@ -0,0 +1,157 @@
<?php
namespace App\Models\Cannaiq;
use App\Models\Business;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* CannaiQ Product Metrics Cache
*
* Stores cached snapshots of product-level intelligence from CannaiQ API.
* Includes pricing, velocity, stock status, and competitive positioning.
*/
class ProductMetric extends Model
{
protected $table = 'cannaiq_product_metrics';
protected $fillable = [
'business_id',
'store_external_id',
'product_external_id',
'snapshot_date',
'raw_payload',
];
protected $casts = [
'snapshot_date' => 'date',
'raw_payload' => 'array',
];
// -------------------------------------------------------------------------
// Relationships
// -------------------------------------------------------------------------
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
// -------------------------------------------------------------------------
// Scopes
// -------------------------------------------------------------------------
public function scopeForBusiness($query, int $businessId)
{
return $query->where('business_id', $businessId);
}
public function scopeForStore($query, string $storeExternalId)
{
return $query->where('store_external_id', $storeExternalId);
}
public function scopeForProduct($query, string $productExternalId)
{
return $query->where('product_external_id', $productExternalId);
}
public function scopeLatest($query)
{
return $query->orderByDesc('snapshot_date');
}
public function scopeInStock($query)
{
return $query->whereJsonContains('raw_payload->in_stock', true);
}
public function scopeOutOfStock($query)
{
return $query->whereJsonContains('raw_payload->in_stock', false);
}
// -------------------------------------------------------------------------
// Accessors
// -------------------------------------------------------------------------
/**
* Get product name from payload
*/
public function getProductNameAttribute(): ?string
{
return $this->raw_payload['name'] ?? null;
}
/**
* Get brand name from payload
*/
public function getBrandNameAttribute(): ?string
{
return $this->raw_payload['brand_name'] ?? null;
}
/**
* Get current price from payload
*/
public function getCurrentPriceAttribute(): ?float
{
return $this->raw_payload['price'] ?? null;
}
/**
* Get original/MSRP price from payload
*/
public function getOriginalPriceAttribute(): ?float
{
return $this->raw_payload['original_price'] ?? null;
}
/**
* Check if product is on sale
*/
public function getIsOnSaleAttribute(): bool
{
$current = $this->current_price;
$original = $this->original_price;
return $current && $original && $current < $original;
}
/**
* Get discount percentage
*/
public function getDiscountPercentAttribute(): ?float
{
if (! $this->is_on_sale) {
return null;
}
return round((1 - ($this->current_price / $this->original_price)) * 100, 1);
}
/**
* Check if in stock
*/
public function getInStockAttribute(): bool
{
return $this->raw_payload['in_stock'] ?? false;
}
/**
* Get THC percentage
*/
public function getThcPercentAttribute(): ?float
{
return $this->raw_payload['thc_percent'] ?? null;
}
/**
* Get category
*/
public function getCategoryAttribute(): ?string
{
return $this->raw_payload['category'] ?? null;
}
}

View File

@@ -0,0 +1,94 @@
<?php
namespace App\Models\Cannaiq;
use App\Models\Business;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* CannaiQ Store Metrics Cache
*
* Stores cached snapshots of store-level intelligence from CannaiQ API.
* Data is refreshed periodically and used for dashboard displays.
*/
class StoreMetric extends Model
{
use SoftDeletes;
protected $table = 'cannaiq_store_metrics';
protected $fillable = [
'business_id',
'store_external_id',
'snapshot_date',
'raw_payload',
];
protected $casts = [
'snapshot_date' => 'date',
'raw_payload' => 'array',
];
// -------------------------------------------------------------------------
// Relationships
// -------------------------------------------------------------------------
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
// -------------------------------------------------------------------------
// Scopes
// -------------------------------------------------------------------------
public function scopeForBusiness($query, int $businessId)
{
return $query->where('business_id', $businessId);
}
public function scopeForStore($query, string $storeExternalId)
{
return $query->where('store_external_id', $storeExternalId);
}
public function scopeLatest($query)
{
return $query->orderByDesc('snapshot_date');
}
public function scopeRecent($query, int $days = 7)
{
return $query->where('snapshot_date', '>=', now()->subDays($days));
}
// -------------------------------------------------------------------------
// Accessors
// -------------------------------------------------------------------------
/**
* Get store name from payload
*/
public function getStoreNameAttribute(): ?string
{
return $this->raw_payload['name'] ?? null;
}
/**
* Get product count from payload
*/
public function getProductCountAttribute(): ?int
{
return $this->raw_payload['product_count'] ?? null;
}
/**
* Get average price from payload
*/
public function getAveragePriceAttribute(): ?float
{
return $this->raw_payload['average_price'] ?? null;
}
}

View File

@@ -46,6 +46,19 @@ class CrmChannel extends Model
self::TYPE_MARKETPLACE => 'Marketplace',
];
// Department constants
public const DEPARTMENT_SALES = 'sales';
public const DEPARTMENT_SUPPORT = 'support';
public const DEPARTMENT_OTHER = 'other';
public const DEPARTMENTS = [
self::DEPARTMENT_SALES => 'Sales',
self::DEPARTMENT_SUPPORT => 'Support',
self::DEPARTMENT_OTHER => 'Other',
];
public const SYNC_STATUS_IDLE = 'idle';
public const SYNC_STATUS_SYNCING = 'syncing';
@@ -56,6 +69,7 @@ class CrmChannel extends Model
'business_id',
'type',
'name',
'department',
'identifier',
'config',
'access_token',
@@ -192,6 +206,20 @@ class CrmChannel extends Model
return self::TYPES[$this->type] ?? ucfirst($this->type);
}
public function getDepartmentLabel(): string
{
return self::DEPARTMENTS[$this->department] ?? ucfirst($this->department ?? 'General');
}
public function scopeForDepartment($query, string|array $department)
{
if (is_array($department)) {
return $query->whereIn('department', $department);
}
return $query->where('department', $department);
}
public function getIcon(): string
{
return match ($this->type) {

View File

@@ -94,7 +94,7 @@ class CrmDeal extends Model
'value' => 'decimal:2',
'weighted_value' => 'decimal:2',
'probability' => 'integer',
'ai_probability' => 'decimal:4',
'ai_probability' => 'integer',
'ai_probability_factors' => 'array',
'products_interested' => 'array',
'custom_fields' => 'array',
@@ -229,7 +229,7 @@ class CrmDeal extends Model
public function scopeAtRisk($query)
{
return $query->open()
->where('ai_probability', '<', 0.3);
->where('ai_probability', '<', 30);
}
// Helpers
@@ -396,6 +396,6 @@ class CrmDeal extends Model
public function getAiProbabilityPercentage(): int
{
return (int) (($this->ai_probability ?? 0) * 100);
return (int) ($this->ai_probability ?? 0);
}
}

125
app/Models/Crm/CrmLead.php Normal file
View File

@@ -0,0 +1,125 @@
<?php
namespace App\Models\Crm;
use App\Models\Business;
use App\Models\User;
use App\Traits\HasHashid;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class CrmLead extends Model
{
use HasFactory, HasHashid, SoftDeletes;
public const STATUSES = [
'new' => 'New',
'contacted' => 'Contacted',
'qualified' => 'Qualified',
'converted' => 'Converted',
'lost' => 'Lost',
];
public const SOURCES = [
'website' => 'Website',
'referral' => 'Referral',
'trade_show' => 'Trade Show',
'cold_call' => 'Cold Call',
'social_media' => 'Social Media',
'email_campaign' => 'Email Campaign',
'other' => 'Other',
];
protected $fillable = [
'hashid',
'seller_business_id',
'company_name',
'dba_name',
'license_number',
'contact_name',
'contact_email',
'contact_phone',
'contact_title',
'city',
'state',
'address',
'zip_code',
'source',
'status',
'notes',
'assigned_to',
'converted_to_business_id',
'converted_at',
];
protected $casts = [
'converted_at' => 'datetime',
];
// Relationships
public function sellerBusiness(): BelongsTo
{
return $this->belongsTo(Business::class, 'seller_business_id');
}
public function assignee(): BelongsTo
{
return $this->belongsTo(User::class, 'assigned_to');
}
public function convertedBusiness(): BelongsTo
{
return $this->belongsTo(Business::class, 'converted_to_business_id');
}
// Scopes
public function scopeForSeller($query, Business $business)
{
return $query->where('seller_business_id', $business->id);
}
public function scopeStatus($query, string $status)
{
return $query->where('status', $status);
}
public function scopeNotConverted($query)
{
return $query->whereNull('converted_at');
}
// Helpers
public function isConverted(): bool
{
return $this->converted_at !== null;
}
public function getStatusBadgeClass(): string
{
return match ($this->status) {
'new' => 'badge-info',
'contacted' => 'badge-warning',
'qualified' => 'badge-primary',
'converted' => 'badge-success',
'lost' => 'badge-ghost',
default => 'badge-ghost',
};
}
public function getFullAddress(): ?string
{
$parts = array_filter([
$this->address,
$this->city,
$this->state,
$this->zip_code,
]);
return $parts ? implode(', ', $parts) : null;
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Models\Crm;
use App\Models\Activity;
use App\Models\Business;
use App\Models\Contact;
use App\Models\Order;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -69,6 +70,9 @@ class CrmQuote extends Model
'signed_by_email',
'signature_ip',
'signature_data',
'order_id',
'notes_customer',
'notes_internal',
];
protected $casts = [
@@ -125,6 +129,11 @@ class CrmQuote extends Model
return $this->hasOne(CrmInvoice::class, 'quote_id');
}
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
public function files(): HasMany
{
return $this->hasMany(CrmAccountFile::class, 'quote_id');
@@ -261,6 +270,45 @@ class CrmQuote extends Model
return $this->valid_until && $this->valid_until->isPast();
}
/**
* Check if quote is expired (accessor for Blade templates)
*/
public function getIsExpiredAttribute(): bool
{
return $this->status === self::STATUS_EXPIRED || (
$this->status === self::STATUS_SENT &&
$this->valid_until && $this->valid_until->isPast()
);
}
/**
* Get amount already invoiced from this quote
*/
public function getAmountInvoicedAttribute(): float
{
return $this->invoice ? (float) $this->invoice->total : 0;
}
/**
* Get remaining amount not yet invoiced
*/
public function getAmountRemainingAttribute(): float
{
return (float) $this->total - $this->amount_invoiced;
}
/**
* Get total units across all quote items
*/
public function getTotalUnitsAttribute(): int
{
return (int) $this->items->sum(function ($item) {
$unitsPerCase = $item->product?->units_per_case ?? 1;
return $item->quantity * $unitsPerCase;
});
}
public function canBeEdited(): bool
{
return $this->status === self::STATUS_DRAFT;

View File

@@ -88,4 +88,69 @@ class CrmQuoteItem extends Model
{
return '$'.number_format($this->unit_price, 2);
}
/**
* Get human-readable quantity display string
* e.g., "2 Cases (24 Units)" or "24 Units" if no case info
*/
public function getQuantityDisplayAttribute(): string
{
$quantity = (int) $this->quantity;
$unitsPerCase = $this->product?->units_per_case;
if ($unitsPerCase && $unitsPerCase > 1) {
$totalUnits = $quantity * $unitsPerCase;
return sprintf(
'%d %s (%d Units)',
$quantity,
$quantity === 1 ? 'Case' : 'Cases',
$totalUnits
);
}
return sprintf('%d %s', $quantity, $quantity === 1 ? 'Unit' : 'Units');
}
/**
* Get total units for this item (accounting for units per case)
*/
public function getTotalUnitsAttribute(): int
{
$unitsPerCase = $this->product?->units_per_case ?? 1;
return (int) ($this->quantity * $unitsPerCase);
}
/**
* Get product name from related product or description
*/
public function getProductNameAttribute(): string
{
return $this->product?->name ?? $this->description ?? 'Unknown Product';
}
/**
* Get brand name from related product
*/
public function getBrandNameAttribute(): ?string
{
return $this->product?->brand?->name;
}
/**
* Get SKU from related product
*/
public function getSkuAttribute(): ?string
{
return $this->product?->sku;
}
/**
* Get product image URL
*/
public function getProductImageUrlAttribute(): ?string
{
return $this->product?->getImageUrl('thumb');
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Models\Crm;
use App\Models\Activity;
use App\Models\Brand;
use App\Models\Business;
use App\Models\Contact;
use App\Models\Conversation;
@@ -50,6 +51,9 @@ class CrmThread extends Model
protected $fillable = [
'business_id',
'brand_id',
'channel_id',
'department',
'contact_id',
'account_id',
'legacy_conversation_id',
@@ -99,6 +103,16 @@ class CrmThread extends Model
return $this->belongsTo(Business::class);
}
public function brand(): BelongsTo
{
return $this->belongsTo(Brand::class);
}
public function channel(): BelongsTo
{
return $this->belongsTo(CrmChannel::class, 'channel_id');
}
public function contact(): BelongsTo
{
return $this->belongsTo(Contact::class);
@@ -206,6 +220,20 @@ class CrmThread extends Model
return $query->where('priority', $priority);
}
public function scopeForDepartment($query, string|array $department)
{
if (is_array($department)) {
return $query->whereIn('department', $department);
}
return $query->where('department', $department);
}
public function scopeForBrand($query, int $brandId)
{
return $query->where('brand_id', $brandId);
}
public function scopeNeedingAttention($query)
{
return $query->open()

View File

@@ -8,6 +8,7 @@ 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;
class Invoice extends Model
@@ -64,6 +65,14 @@ class Invoice extends Model
return $this->belongsTo(Business::class);
}
/**
* Get payments for this invoice.
*/
public function payments(): HasMany
{
return $this->hasMany(InvoicePayment::class);
}
/**
* Scope: Get unpaid invoices.
*/
@@ -107,6 +116,14 @@ class Invoice extends Model
&& $this->due_date->isPast();
}
/**
* Get the is_overdue attribute.
*/
public function getIsOverdueAttribute(): bool
{
return $this->isOverdue();
}
/**
* Check if invoice is paid.
*/
@@ -159,6 +176,24 @@ class Invoice extends Model
return $this->markPaid($amount);
}
/**
* Recalculate amounts after payment changes.
*/
public function recalculateAmounts(): void
{
$totalPaid = $this->payments()->sum('amount');
$this->amount_paid = $totalPaid;
$this->amount_due = max(0, $this->total - $totalPaid);
if ($this->amount_due <= 0) {
$this->payment_status = 'paid';
} elseif ($this->amount_paid > 0) {
$this->payment_status = 'partially_paid';
}
$this->save();
}
/**
* Get the payment status badge color.
*/
@@ -172,4 +207,23 @@ class Invoice extends Model
default => 'gray',
};
}
/**
* Get the payment terms label.
*/
public function getPaymentTermsLabelAttribute(): ?string
{
if (! $this->order) {
return null;
}
return match ($this->order->payment_terms) {
'cod' => 'Cash on Delivery',
'net_15' => 'Net 15',
'net_30' => 'Net 30',
'net_60' => 'Net 60',
'net_90' => 'Net 90',
default => ucfirst(str_replace('_', ' ', $this->order->payment_terms ?? '')),
};
}
}

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Invoice Payment - Payment record for an invoice
*
* Tracks individual payments against invoices, supporting
* partial payments and multiple payment methods.
*/
class InvoicePayment extends Model
{
public const METHOD_CASH = 'cash';
public const METHOD_CHECK = 'check';
public const METHOD_CREDIT_CARD = 'credit_card';
public const METHOD_BANK_TRANSFER = 'bank_transfer';
public const METHOD_ACH = 'ach';
public const METHOD_WIRE = 'wire';
public const METHOD_OTHER = 'other';
public const METHODS = [
self::METHOD_CASH => 'Cash',
self::METHOD_CHECK => 'Check',
self::METHOD_CREDIT_CARD => 'Credit Card',
self::METHOD_BANK_TRANSFER => 'Bank Transfer',
self::METHOD_ACH => 'ACH',
self::METHOD_WIRE => 'Wire Transfer',
self::METHOD_OTHER => 'Other',
];
protected $fillable = [
'invoice_id',
'amount',
'payment_method',
'payment_date',
'reference',
'notes',
'recorded_by',
];
protected $casts = [
'amount' => 'decimal:2',
'payment_date' => 'date',
];
protected static function booted(): void
{
static::created(function (InvoicePayment $payment) {
$payment->invoice->recalculateAmounts();
});
static::deleted(function (InvoicePayment $payment) {
$payment->invoice->recalculateAmounts();
});
}
// Relationships
public function invoice(): BelongsTo
{
return $this->belongsTo(Invoice::class);
}
public function recordedByUser(): BelongsTo
{
return $this->belongsTo(User::class, 'recorded_by');
}
// Helpers
public function getMethodLabel(): string
{
return self::METHODS[$this->payment_method] ?? ucfirst(str_replace('_', ' ', $this->payment_method));
}
public function getFormattedAmount(): string
{
return '$'.number_format((float) $this->amount, 2);
}
}

View File

@@ -0,0 +1,312 @@
<?php
namespace App\Models\Marketing;
use App\Models\Business;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class MarketingAutomation extends Model
{
// Trigger types
public const TRIGGER_SCHEDULED_CANNAIQ_CHECK = 'scheduled_cannaiq_check';
public const TRIGGER_SCHEDULED_STORE_CHECK = 'scheduled_store_check';
public const TRIGGER_MANUAL_TEST = 'manual_test';
public const TRIGGER_TYPES = [
self::TRIGGER_SCHEDULED_CANNAIQ_CHECK => 'Scheduled CannaiQ Check',
self::TRIGGER_SCHEDULED_STORE_CHECK => 'Scheduled Store Check',
self::TRIGGER_MANUAL_TEST => 'Manual Test',
];
// Condition types
public const CONDITION_COMPETITOR_OUT_OF_STOCK = 'competitor_out_of_stock_and_we_have_inventory';
public const CONDITION_SLOW_MOVER_CLEARANCE = 'slow_mover_clearance';
public const CONDITION_NEW_STORE_LAUNCH = 'new_store_launch';
public const CONDITION_CATEGORY_MOMENTUM = 'category_momentum_shift';
public const CONDITION_BRAND_RANK_DROP = 'brand_rank_drop';
public const CONDITION_TYPES = [
self::CONDITION_COMPETITOR_OUT_OF_STOCK => 'Competitor Out of Stock (We Have Inventory)',
self::CONDITION_SLOW_MOVER_CLEARANCE => 'Slow Mover Clearance',
self::CONDITION_NEW_STORE_LAUNCH => 'New Store Launch',
self::CONDITION_CATEGORY_MOMENTUM => 'Category Momentum Shift',
self::CONDITION_BRAND_RANK_DROP => 'Brand Rank Drop',
];
// Scope types
public const SCOPE_INTERNAL = 'internal';
public const SCOPE_PORTAL = 'portal';
public const SCOPES = [
self::SCOPE_INTERNAL => 'Internal Only',
self::SCOPE_PORTAL => 'Portal Visible',
];
// Frequency options
public const FREQUENCY_HOURLY = 'hourly';
public const FREQUENCY_DAILY = 'daily';
public const FREQUENCY_WEEKLY = 'weekly';
public const FREQUENCIES = [
self::FREQUENCY_HOURLY => 'Hourly',
self::FREQUENCY_DAILY => 'Daily',
self::FREQUENCY_WEEKLY => 'Weekly',
];
// Promo types for actions
public const PROMO_TYPES = [
'bogo' => 'BOGO (Buy One Get One)',
'percent_off' => 'Percent Off',
'set_price' => 'Set Price',
'bundle' => 'Bundle Deal',
'flash_sale' => 'Flash Sale',
'clearance' => 'Clearance',
'launch_special' => 'Launch Special',
];
protected $fillable = [
'business_id',
'name',
'description',
'is_active',
'scope',
'trigger_type',
'trigger_config',
'condition_config',
'action_config',
'last_run_at',
'last_status',
'meta',
];
protected $casts = [
'is_active' => 'boolean',
'trigger_config' => 'array',
'condition_config' => 'array',
'action_config' => 'array',
'meta' => 'array',
'last_run_at' => 'datetime',
];
// Relationships
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function runs(): HasMany
{
return $this->hasMany(MarketingAutomationRun::class);
}
public function latestRun(): HasMany
{
return $this->runs()->latest('started_at')->limit(1);
}
// Accessors
public function getTriggerTypeLabelAttribute(): string
{
return self::TRIGGER_TYPES[$this->trigger_type] ?? $this->trigger_type;
}
public function getConditionTypeLabelAttribute(): string
{
$type = $this->condition_config['type'] ?? 'unknown';
return self::CONDITION_TYPES[$type] ?? $type;
}
public function getScopeLabelAttribute(): string
{
return self::SCOPES[$this->scope] ?? $this->scope;
}
public function getFrequencyAttribute(): string
{
return $this->trigger_config['frequency'] ?? self::FREQUENCY_DAILY;
}
public function getFrequencyLabelAttribute(): string
{
$frequency = $this->frequency;
return self::FREQUENCIES[$frequency] ?? $frequency;
}
public function getStatusColorAttribute(): string
{
return match ($this->last_status) {
'success' => 'success',
'partial' => 'warning',
'error', 'failed' => 'error',
'skipped' => 'ghost',
default => 'ghost',
};
}
// Methods
/**
* Check if the automation is due to run based on its schedule.
*/
public function isDue(): bool
{
if (! $this->is_active) {
return false;
}
if ($this->trigger_type === self::TRIGGER_MANUAL_TEST) {
return false; // Manual automations are never auto-triggered
}
$frequency = $this->trigger_config['frequency'] ?? self::FREQUENCY_DAILY;
$timeOfDay = $this->trigger_config['time_of_day'] ?? '09:00';
$minuteOffset = $this->trigger_config['minute_offset'] ?? 0;
$now = Carbon::now();
// If never run, it's due (assuming time matches for daily/weekly)
if (! $this->last_run_at) {
return $this->timeMatchesSchedule($now, $frequency, $timeOfDay, $minuteOffset);
}
return match ($frequency) {
self::FREQUENCY_HOURLY => $this->last_run_at->diffInMinutes($now) >= 60 - 5, // 5-min buffer
self::FREQUENCY_DAILY => $this->last_run_at->diffInHours($now) >= 23 && $this->timeMatchesSchedule($now, $frequency, $timeOfDay, $minuteOffset),
self::FREQUENCY_WEEKLY => $this->last_run_at->diffInDays($now) >= 6 && $this->timeMatchesSchedule($now, $frequency, $timeOfDay, $minuteOffset),
default => false,
};
}
/**
* Check if current time matches the scheduled time.
*/
protected function timeMatchesSchedule(Carbon $now, string $frequency, string $timeOfDay, int $minuteOffset): bool
{
if ($frequency === self::FREQUENCY_HOURLY) {
return $now->minute >= $minuteOffset && $now->minute < $minuteOffset + 10;
}
// Parse time of day (HH:MM format)
[$hour, $minute] = array_map('intval', explode(':', $timeOfDay));
// Allow a 30-minute window after scheduled time
$scheduledMinutes = $hour * 60 + $minute;
$currentMinutes = $now->hour * 60 + $now->minute;
return $currentMinutes >= $scheduledMinutes && $currentMinutes < $scheduledMinutes + 30;
}
/**
* Get automation type preset info for the UI.
*/
public static function getTypePresets(): array
{
return [
'competitor_flash_sale' => [
'name' => 'Competitor Out of Stock → Flash Sale',
'description' => 'When competitors are out of stock in your category and you have inventory, automatically create a flash promo and SMS campaign.',
'trigger_type' => self::TRIGGER_SCHEDULED_CANNAIQ_CHECK,
'condition_type' => self::CONDITION_COMPETITOR_OUT_OF_STOCK,
'default_trigger_config' => [
'frequency' => self::FREQUENCY_HOURLY,
'minute_offset' => 5,
'store_scope' => 'all',
],
'default_condition_config' => [
'type' => self::CONDITION_COMPETITOR_OUT_OF_STOCK,
'category' => null,
'min_inventory_units' => 30,
'min_price_advantage' => 0.1,
],
'default_action_config' => [
'create_promo' => [
'promo_type' => 'flash_bogo',
'duration_hours' => 24,
],
'create_campaign' => [
'channels' => ['sms'],
'list_type' => 'consumers',
'send_mode' => 'immediate',
'subject_template' => 'Flash Deal: {product_name} Today Only',
'sms_body_template' => '🔥 Flash deal today at {store_name}: {promo_text}',
],
],
],
'slow_mover_clearance' => [
'name' => 'Slow Movers → Clearance Email',
'description' => 'For slow-moving inventory, automatically create clearance promos and email campaigns to deal-seekers.',
'trigger_type' => self::TRIGGER_SCHEDULED_CANNAIQ_CHECK,
'condition_type' => self::CONDITION_SLOW_MOVER_CLEARANCE,
'default_trigger_config' => [
'frequency' => self::FREQUENCY_DAILY,
'time_of_day' => '09:00',
],
'default_condition_config' => [
'type' => self::CONDITION_SLOW_MOVER_CLEARANCE,
'velocity_30d_threshold' => 5,
'min_inventory_units' => 50,
'min_days_in_stock' => 30,
],
'default_action_config' => [
'create_promo' => [
'promo_type' => 'clearance',
'duration_hours' => 168, // 7 days
'discount_percent' => 20,
],
'create_campaign' => [
'channels' => ['email'],
'list_type' => 'consumers',
'list_tags' => ['deal_seeker', 'loyal'],
'send_mode' => 'immediate',
'subject_template' => 'Clearance Alert: Save on {product_name}',
'email_body_template' => 'Great deals on premium products. {promo_text}',
],
],
],
'new_store_launch' => [
'name' => 'New Store Launch → Welcome Blast',
'description' => 'When your brand appears at a new store in CannaiQ, automatically send a welcome campaign.',
'trigger_type' => self::TRIGGER_SCHEDULED_STORE_CHECK,
'condition_type' => self::CONDITION_NEW_STORE_LAUNCH,
'default_trigger_config' => [
'frequency' => self::FREQUENCY_DAILY,
'time_of_day' => '10:00',
],
'default_condition_config' => [
'type' => self::CONDITION_NEW_STORE_LAUNCH,
'first_appearance_window_days' => 7,
],
'default_action_config' => [
'create_promo' => [
'promo_type' => 'launch_special',
'duration_hours' => 168, // 7 days
],
'create_campaign' => [
'channels' => ['email', 'sms'],
'list_type' => 'consumers',
'send_mode' => 'immediate',
'subject_template' => 'Now Available: {brand_name} at {store_name}',
'sms_body_template' => '🎉 {brand_name} is now at {store_name}! {promo_text}',
'email_body_template' => 'Exciting news! {brand_name} products are now available at {store_name}.',
],
],
],
];
}
}

View File

@@ -0,0 +1,169 @@
<?php
namespace App\Models\Marketing;
use App\Models\Business;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class MarketingAutomationRun extends Model
{
public const STATUS_RUNNING = 'running';
public const STATUS_SUCCESS = 'success';
public const STATUS_PARTIAL = 'partial';
public const STATUS_FAILED = 'failed';
public const STATUS_SKIPPED = 'skipped';
public const STATUSES = [
self::STATUS_RUNNING => 'Running',
self::STATUS_SUCCESS => 'Success',
self::STATUS_PARTIAL => 'Partial',
self::STATUS_FAILED => 'Failed',
self::STATUS_SKIPPED => 'Skipped',
];
protected $fillable = [
'business_id',
'marketing_automation_id',
'started_at',
'finished_at',
'status',
'summary',
'details',
];
protected $casts = [
'started_at' => 'datetime',
'finished_at' => 'datetime',
'details' => 'array',
];
// Relationships
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function automation(): BelongsTo
{
return $this->belongsTo(MarketingAutomation::class, 'marketing_automation_id');
}
// Accessors
public function getStatusLabelAttribute(): string
{
return self::STATUSES[$this->status] ?? $this->status;
}
public function getStatusColorAttribute(): string
{
return match ($this->status) {
self::STATUS_RUNNING => 'info',
self::STATUS_SUCCESS => 'success',
self::STATUS_PARTIAL => 'warning',
self::STATUS_FAILED => 'error',
self::STATUS_SKIPPED => 'ghost',
default => 'ghost',
};
}
public function getDurationAttribute(): ?string
{
if (! $this->started_at) {
return null;
}
$end = $this->finished_at ?? now();
$seconds = $this->started_at->diffInSeconds($end);
if ($seconds < 60) {
return "{$seconds}s";
}
$minutes = floor($seconds / 60);
$remainingSeconds = $seconds % 60;
return "{$minutes}m {$remainingSeconds}s";
}
// Methods
/**
* Mark the run as started.
*/
public static function start(MarketingAutomation $automation): self
{
return self::create([
'business_id' => $automation->business_id,
'marketing_automation_id' => $automation->id,
'started_at' => now(),
'status' => self::STATUS_RUNNING,
'details' => [],
]);
}
/**
* Mark the run as finished with success.
*/
public function succeed(string $summary, array $details = []): self
{
return $this->finish(self::STATUS_SUCCESS, $summary, $details);
}
/**
* Mark the run as finished with partial success.
*/
public function partial(string $summary, array $details = []): self
{
return $this->finish(self::STATUS_PARTIAL, $summary, $details);
}
/**
* Mark the run as failed.
*/
public function fail(string $summary, array $details = []): self
{
return $this->finish(self::STATUS_FAILED, $summary, $details);
}
/**
* Mark the run as skipped (conditions not met).
*/
public function skip(string $summary, array $details = []): self
{
return $this->finish(self::STATUS_SKIPPED, $summary, $details);
}
/**
* Finish the run with the given status.
*/
protected function finish(string $status, string $summary, array $details = []): self
{
$this->update([
'finished_at' => now(),
'status' => $status,
'summary' => $summary,
'details' => array_merge($this->details ?? [], $details),
]);
return $this;
}
/**
* Add details to the run.
*/
public function addDetails(array $details): self
{
$this->update([
'details' => array_merge($this->details ?? [], $details),
]);
return $this;
}
}

View File

@@ -0,0 +1,340 @@
<?php
namespace App\Models\Marketing;
use App\Models\Business;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* MarketingCampaign - Email or SMS marketing campaign.
*
* Targets a MarketingList and tracks send status and metrics.
*/
class MarketingCampaign extends Model
{
use HasFactory;
public const CHANNEL_EMAIL = 'email';
public const CHANNEL_SMS = 'sms';
public const CHANNEL_MULTI = 'multi';
public const CHANNELS = [
self::CHANNEL_EMAIL => 'Email',
self::CHANNEL_SMS => 'SMS',
self::CHANNEL_MULTI => 'Email & SMS',
];
public const STATUS_DRAFT = 'draft';
public const STATUS_SCHEDULED = 'scheduled';
public const STATUS_SENDING = 'sending';
public const STATUS_SENT = 'sent';
public const STATUS_CANCELLED = 'cancelled';
public const STATUSES = [
self::STATUS_DRAFT => 'Draft',
self::STATUS_SCHEDULED => 'Scheduled',
self::STATUS_SENDING => 'Sending',
self::STATUS_SENT => 'Sent',
self::STATUS_CANCELLED => 'Cancelled',
];
public const SOURCE_MANUAL = 'manual';
public const SOURCE_PROMO = 'promo';
public const SOURCE_AUTOMATION = 'automation';
public const SOURCES = [
self::SOURCE_MANUAL => 'Manual',
self::SOURCE_PROMO => 'Promo Builder',
self::SOURCE_AUTOMATION => 'Automation',
];
protected $fillable = [
'business_id',
'name',
'channel',
'status',
'marketing_list_id',
'marketing_template_id',
'subject',
'email_preview_text',
'sms_body',
'email_body_html',
'from_name',
'from_email',
'send_at',
'sent_at',
'metrics',
'source_type',
'source_id',
];
protected $casts = [
'send_at' => 'datetime',
'sent_at' => 'datetime',
'metrics' => 'array',
];
// ─────────────────────────────────────────────────────────────
// Relationships
// ─────────────────────────────────────────────────────────────
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function list(): BelongsTo
{
return $this->belongsTo(MarketingList::class, 'marketing_list_id');
}
public function template(): BelongsTo
{
return $this->belongsTo(MarketingTemplate::class, 'marketing_template_id');
}
public function messageLogs(): HasMany
{
return $this->hasMany(MarketingMessageLog::class);
}
public function promo(): ?BelongsTo
{
if ($this->source_type !== self::SOURCE_PROMO) {
return null;
}
return $this->belongsTo(MarketingPromo::class, 'source_id');
}
// ─────────────────────────────────────────────────────────────
// Scopes
// ─────────────────────────────────────────────────────────────
public function scopeForBusiness(Builder $query, int $businessId): Builder
{
return $query->where('business_id', $businessId);
}
public function scopeDraft(Builder $query): Builder
{
return $query->where('status', self::STATUS_DRAFT);
}
public function scopeScheduled(Builder $query): Builder
{
return $query->where('status', self::STATUS_SCHEDULED);
}
public function scopeSending(Builder $query): Builder
{
return $query->where('status', self::STATUS_SENDING);
}
public function scopeSent(Builder $query): Builder
{
return $query->where('status', self::STATUS_SENT);
}
public function scopeReadyToSend(Builder $query): Builder
{
return $query->where('status', self::STATUS_SCHEDULED)
->where('send_at', '<=', now());
}
public function scopeChannel(Builder $query, string $channel): Builder
{
return $query->where('channel', $channel);
}
// ─────────────────────────────────────────────────────────────
// Accessors
// ─────────────────────────────────────────────────────────────
public function getChannelLabel(): string
{
return self::CHANNELS[$this->channel] ?? $this->channel;
}
public function getStatusLabel(): string
{
return self::STATUSES[$this->status] ?? $this->status;
}
public function getSourceLabel(): string
{
return self::SOURCES[$this->source_type] ?? $this->source_type;
}
public function getRecipientCountAttribute(): int
{
if (! $this->list) {
return 0;
}
if ($this->channel === self::CHANNEL_EMAIL) {
return $this->list->email_subscriber_count;
}
if ($this->channel === self::CHANNEL_SMS) {
return $this->list->sms_subscriber_count;
}
return $this->list->contact_count;
}
public function getSentCountAttribute(): int
{
return $this->messageLogs()->where('status', 'sent')->count();
}
public function getFailedCountAttribute(): int
{
return $this->messageLogs()->where('status', 'failed')->count();
}
public function getOpenedCountAttribute(): int
{
return $this->messageLogs()->whereNotNull('opened_at')->count();
}
public function getClickedCountAttribute(): int
{
return $this->messageLogs()->whereNotNull('clicked_at')->count();
}
// ─────────────────────────────────────────────────────────────
// Status Helpers
// ─────────────────────────────────────────────────────────────
public function isDraft(): bool
{
return $this->status === self::STATUS_DRAFT;
}
public function isScheduled(): bool
{
return $this->status === self::STATUS_SCHEDULED;
}
public function isSending(): bool
{
return $this->status === self::STATUS_SENDING;
}
public function isSent(): bool
{
return $this->status === self::STATUS_SENT;
}
public function isCancelled(): bool
{
return $this->status === self::STATUS_CANCELLED;
}
public function canEdit(): bool
{
return in_array($this->status, [self::STATUS_DRAFT, self::STATUS_SCHEDULED]);
}
public function canSend(): bool
{
return $this->status === self::STATUS_DRAFT && $this->marketing_list_id;
}
public function canSchedule(): bool
{
return $this->status === self::STATUS_DRAFT && $this->marketing_list_id;
}
public function canCancel(): bool
{
return in_array($this->status, [self::STATUS_DRAFT, self::STATUS_SCHEDULED]);
}
// ─────────────────────────────────────────────────────────────
// Actions
// ─────────────────────────────────────────────────────────────
public function schedule(\DateTimeInterface $sendAt): void
{
$this->update([
'status' => self::STATUS_SCHEDULED,
'send_at' => $sendAt,
]);
}
public function markSending(): void
{
$this->update(['status' => self::STATUS_SENDING]);
}
public function markSent(): void
{
$this->update([
'status' => self::STATUS_SENT,
'sent_at' => now(),
]);
}
public function cancel(): void
{
$this->update(['status' => self::STATUS_CANCELLED]);
}
public function updateMetrics(): void
{
$this->update([
'metrics' => [
'total' => $this->messageLogs()->count(),
'sent' => $this->sent_count,
'failed' => $this->failed_count,
'opened' => $this->opened_count,
'clicked' => $this->clicked_count,
'updated_at' => now()->toIso8601String(),
],
]);
}
// ─────────────────────────────────────────────────────────────
// Content Helpers
// ─────────────────────────────────────────────────────────────
public function hasEmailContent(): bool
{
return ! empty($this->subject) && ! empty($this->email_body_html);
}
public function hasSmsContent(): bool
{
return ! empty($this->sms_body);
}
public function getSmsCharacterCount(): int
{
return strlen($this->sms_body ?? '');
}
public function getSmsSegmentCount(): int
{
$length = $this->getSmsCharacterCount();
if ($length <= 160) {
return 1;
}
return (int) ceil($length / 153);
}
}

View File

@@ -0,0 +1,205 @@
<?php
namespace App\Models\Marketing;
use App\Models\Business;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* MarketingContact - Marketing-specific contact for campaigns.
*
* Supports B2B (buyers), B2C (consumers), and internal contacts.
* Can optionally link to existing Contact, Business, or CrmLead records.
*/
class MarketingContact extends Model
{
use HasFactory;
use SoftDeletes;
public const TYPE_BUYER = 'buyer';
public const TYPE_CONSUMER = 'consumer';
public const TYPE_INTERNAL = 'internal';
public const TYPES = [
self::TYPE_BUYER => 'Buyer',
self::TYPE_CONSUMER => 'Consumer',
self::TYPE_INTERNAL => 'Internal',
];
public const SOURCE_MANUAL = 'manual';
public const SOURCE_IMPORT = 'import';
public const SOURCE_SYNCED = 'synced';
public const SOURCE_API = 'api';
public const SOURCES = [
self::SOURCE_MANUAL => 'Manual Entry',
self::SOURCE_IMPORT => 'CSV Import',
self::SOURCE_SYNCED => 'Synced from CRM',
self::SOURCE_API => 'API',
];
protected $fillable = [
'business_id',
'type',
'email',
'phone',
'first_name',
'last_name',
'tags',
'source',
'related_type',
'related_id',
'is_subscribed_email',
'is_subscribed_sms',
'meta',
];
protected $casts = [
'tags' => 'array',
'meta' => 'array',
'is_subscribed_email' => 'boolean',
'is_subscribed_sms' => 'boolean',
];
// ─────────────────────────────────────────────────────────────
// Relationships
// ─────────────────────────────────────────────────────────────
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function related(): MorphTo
{
return $this->morphTo();
}
public function lists(): BelongsToMany
{
return $this->belongsToMany(
MarketingList::class,
'marketing_list_contact',
'marketing_contact_id',
'marketing_list_id'
)->withTimestamps();
}
public function messageLogs(): HasMany
{
return $this->hasMany(MarketingMessageLog::class);
}
// ─────────────────────────────────────────────────────────────
// Scopes
// ─────────────────────────────────────────────────────────────
public function scopeForBusiness(Builder $query, int $businessId): Builder
{
return $query->where('business_id', $businessId);
}
public function scopeOfType(Builder $query, string $type): Builder
{
return $query->where('type', $type);
}
public function scopeBuyers(Builder $query): Builder
{
return $query->where('type', self::TYPE_BUYER);
}
public function scopeConsumers(Builder $query): Builder
{
return $query->where('type', self::TYPE_CONSUMER);
}
public function scopeSubscribedEmail(Builder $query): Builder
{
return $query->where('is_subscribed_email', true)->whereNotNull('email');
}
public function scopeSubscribedSms(Builder $query): Builder
{
return $query->where('is_subscribed_sms', true)->whereNotNull('phone');
}
public function scopeWithTag(Builder $query, string $tag): Builder
{
return $query->whereJsonContains('tags', $tag);
}
// ─────────────────────────────────────────────────────────────
// Accessors
// ─────────────────────────────────────────────────────────────
public function getFullNameAttribute(): string
{
return trim("{$this->first_name} {$this->last_name}") ?: $this->email ?: $this->phone ?: 'Unknown';
}
public function getDisplayNameAttribute(): string
{
if ($this->first_name || $this->last_name) {
return $this->full_name;
}
return $this->email ?: $this->phone ?: 'Contact #'.$this->id;
}
public function getTypeLabel(): string
{
return self::TYPES[$this->type] ?? $this->type;
}
public function getSourceLabel(): string
{
return self::SOURCES[$this->source] ?? $this->source;
}
// ─────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────
public function canReceiveEmail(): bool
{
return $this->is_subscribed_email && ! empty($this->email);
}
public function canReceiveSms(): bool
{
return $this->is_subscribed_sms && ! empty($this->phone);
}
public function addTag(string $tag): void
{
$tags = $this->tags ?? [];
if (! in_array($tag, $tags)) {
$tags[] = $tag;
$this->update(['tags' => $tags]);
}
}
public function removeTag(string $tag): void
{
$tags = $this->tags ?? [];
$this->update(['tags' => array_values(array_diff($tags, [$tag]))]);
}
public function hasTag(string $tag): bool
{
return in_array($tag, $this->tags ?? []);
}
}

View File

@@ -0,0 +1,218 @@
<?php
namespace App\Models\Marketing;
use App\Models\Business;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* MarketingList - Collection of marketing contacts for campaigns.
*
* Supports static lists (manually curated) and smart lists (filter-based).
*/
class MarketingList extends Model
{
use HasFactory;
public const TYPE_STATIC = 'static';
public const TYPE_SMART = 'smart';
public const TYPES = [
self::TYPE_STATIC => 'Static List',
self::TYPE_SMART => 'Smart List',
];
protected $fillable = [
'business_id',
'name',
'description',
'type',
'filters',
];
protected $casts = [
'filters' => 'array',
];
// ─────────────────────────────────────────────────────────────
// Relationships
// ─────────────────────────────────────────────────────────────
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function contacts(): BelongsToMany
{
return $this->belongsToMany(
MarketingContact::class,
'marketing_list_contact',
'marketing_list_id',
'marketing_contact_id'
)->withTimestamps();
}
public function campaigns(): HasMany
{
return $this->hasMany(MarketingCampaign::class);
}
// ─────────────────────────────────────────────────────────────
// Scopes
// ─────────────────────────────────────────────────────────────
public function scopeForBusiness(Builder $query, int $businessId): Builder
{
return $query->where('business_id', $businessId);
}
public function scopeStatic(Builder $query): Builder
{
return $query->where('type', self::TYPE_STATIC);
}
public function scopeSmart(Builder $query): Builder
{
return $query->where('type', self::TYPE_SMART);
}
// ─────────────────────────────────────────────────────────────
// Accessors
// ─────────────────────────────────────────────────────────────
public function getTypeLabel(): string
{
return self::TYPES[$this->type] ?? $this->type;
}
public function getContactCountAttribute(): int
{
if ($this->type === self::TYPE_SMART) {
return $this->getSmartListContacts()->count();
}
return $this->contacts()->count();
}
public function getEmailSubscriberCountAttribute(): int
{
return $this->getEligibleContacts('email')->count();
}
public function getSmsSubscriberCountAttribute(): int
{
return $this->getEligibleContacts('sms')->count();
}
// ─────────────────────────────────────────────────────────────
// List Contacts
// ─────────────────────────────────────────────────────────────
/**
* Get contacts for this list (handles both static and smart lists).
*/
public function getContacts(): Builder
{
if ($this->type === self::TYPE_SMART) {
return $this->getSmartListContacts();
}
return MarketingContact::whereHas('lists', fn ($q) => $q->where('marketing_lists.id', $this->id));
}
/**
* Get contacts eligible for a specific channel.
*/
public function getEligibleContacts(string $channel): Builder
{
$query = $this->getContacts();
if ($channel === 'email') {
return $query->subscribedEmail();
}
if ($channel === 'sms') {
return $query->subscribedSms();
}
return $query;
}
/**
* Build smart list query from filters.
*/
protected function getSmartListContacts(): Builder
{
$query = MarketingContact::forBusiness($this->business_id);
$filters = $this->filters ?? [];
if (! empty($filters['type'])) {
$query->ofType($filters['type']);
}
if (! empty($filters['tags'])) {
foreach ((array) $filters['tags'] as $tag) {
$query->withTag($tag);
}
}
if (isset($filters['subscribed_email'])) {
$query->where('is_subscribed_email', $filters['subscribed_email']);
}
if (isset($filters['subscribed_sms'])) {
$query->where('is_subscribed_sms', $filters['subscribed_sms']);
}
if (! empty($filters['source'])) {
$query->where('source', $filters['source']);
}
return $query;
}
// ─────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────
public function isStatic(): bool
{
return $this->type === self::TYPE_STATIC;
}
public function isSmart(): bool
{
return $this->type === self::TYPE_SMART;
}
/**
* Add contacts to static list.
*/
public function addContacts(array $contactIds): void
{
if ($this->type !== self::TYPE_STATIC) {
throw new \LogicException('Cannot manually add contacts to a smart list');
}
$this->contacts()->syncWithoutDetaching($contactIds);
}
/**
* Remove contacts from static list.
*/
public function removeContacts(array $contactIds): void
{
if ($this->type !== self::TYPE_STATIC) {
throw new \LogicException('Cannot manually remove contacts from a smart list');
}
$this->contacts()->detach($contactIds);
}
}

View File

@@ -0,0 +1,224 @@
<?php
namespace App\Models\Marketing;
use App\Models\Business;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* MarketingMessageLog - Individual message send log.
*
* Tracks each email or SMS sent to a contact as part of a campaign.
*/
class MarketingMessageLog extends Model
{
use HasFactory;
public const CHANNEL_EMAIL = 'email';
public const CHANNEL_SMS = 'sms';
public const STATUS_QUEUED = 'queued';
public const STATUS_SENT = 'sent';
public const STATUS_DELIVERED = 'delivered';
public const STATUS_FAILED = 'failed';
public const STATUS_BOUNCED = 'bounced';
public const STATUSES = [
self::STATUS_QUEUED => 'Queued',
self::STATUS_SENT => 'Sent',
self::STATUS_DELIVERED => 'Delivered',
self::STATUS_FAILED => 'Failed',
self::STATUS_BOUNCED => 'Bounced',
];
protected $fillable = [
'business_id',
'marketing_campaign_id',
'marketing_contact_id',
'channel',
'status',
'to',
'provider_message_id',
'error_message',
'sent_at',
'delivered_at',
'opened_at',
'clicked_at',
];
protected $casts = [
'sent_at' => 'datetime',
'delivered_at' => 'datetime',
'opened_at' => 'datetime',
'clicked_at' => 'datetime',
];
// ─────────────────────────────────────────────────────────────
// Relationships
// ─────────────────────────────────────────────────────────────
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function campaign(): BelongsTo
{
return $this->belongsTo(MarketingCampaign::class, 'marketing_campaign_id');
}
public function contact(): BelongsTo
{
return $this->belongsTo(MarketingContact::class, 'marketing_contact_id');
}
// ─────────────────────────────────────────────────────────────
// Scopes
// ─────────────────────────────────────────────────────────────
public function scopeForBusiness(Builder $query, int $businessId): Builder
{
return $query->where('business_id', $businessId);
}
public function scopeForCampaign(Builder $query, int $campaignId): Builder
{
return $query->where('marketing_campaign_id', $campaignId);
}
public function scopeForContact(Builder $query, int $contactId): Builder
{
return $query->where('marketing_contact_id', $contactId);
}
public function scopeEmail(Builder $query): Builder
{
return $query->where('channel', self::CHANNEL_EMAIL);
}
public function scopeSms(Builder $query): Builder
{
return $query->where('channel', self::CHANNEL_SMS);
}
public function scopeStatus(Builder $query, string $status): Builder
{
return $query->where('status', $status);
}
public function scopeSent(Builder $query): Builder
{
return $query->where('status', self::STATUS_SENT);
}
public function scopeFailed(Builder $query): Builder
{
return $query->where('status', self::STATUS_FAILED);
}
// ─────────────────────────────────────────────────────────────
// Accessors
// ─────────────────────────────────────────────────────────────
public function getStatusLabel(): string
{
return self::STATUSES[$this->status] ?? $this->status;
}
public function isEmail(): bool
{
return $this->channel === self::CHANNEL_EMAIL;
}
public function isSms(): bool
{
return $this->channel === self::CHANNEL_SMS;
}
public function wasOpened(): bool
{
return $this->opened_at !== null;
}
public function wasClicked(): bool
{
return $this->clicked_at !== null;
}
// ─────────────────────────────────────────────────────────────
// Status Updates
// ─────────────────────────────────────────────────────────────
public function markSent(?string $providerMessageId = null): void
{
$this->update([
'status' => self::STATUS_SENT,
'sent_at' => now(),
'provider_message_id' => $providerMessageId,
]);
}
public function markDelivered(): void
{
$this->update([
'status' => self::STATUS_DELIVERED,
'delivered_at' => now(),
]);
}
public function markFailed(string $errorMessage): void
{
$this->update([
'status' => self::STATUS_FAILED,
'error_message' => $errorMessage,
]);
}
public function markBounced(?string $reason = null): void
{
$this->update([
'status' => self::STATUS_BOUNCED,
'error_message' => $reason,
]);
}
public function recordOpen(): void
{
if (! $this->opened_at) {
$this->update(['opened_at' => now()]);
}
}
public function recordClick(): void
{
$this->update(['clicked_at' => now()]);
}
// ─────────────────────────────────────────────────────────────
// Factory Methods
// ─────────────────────────────────────────────────────────────
public static function createQueued(
MarketingCampaign $campaign,
MarketingContact $contact,
string $channel,
string $to
): self {
return self::create([
'business_id' => $campaign->business_id,
'marketing_campaign_id' => $campaign->id,
'marketing_contact_id' => $contact->id,
'channel' => $channel,
'status' => self::STATUS_QUEUED,
'to' => $to,
]);
}
}

View File

@@ -0,0 +1,270 @@
<?php
namespace App\Models\Marketing;
use App\Models\Brand;
use App\Models\Business;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* Marketing Promo
*
* Represents a promotional offer created through the Promo Builder.
* Can be targeted to specific stores, brands, or categories.
*/
class MarketingPromo extends Model
{
use SoftDeletes;
protected $table = 'marketing_promos';
protected $fillable = [
'business_id',
'store_external_id',
'brand_id',
'name',
'type',
'config',
'expected_lift',
'expected_margin_brand',
'expected_margin_store',
'status',
'starts_at',
'ends_at',
'description',
'sms_copy',
'email_copy',
'created_by',
];
protected $casts = [
'config' => 'array',
'expected_lift' => 'decimal:2',
'expected_margin_brand' => 'decimal:2',
'expected_margin_store' => 'decimal:2',
'starts_at' => 'datetime',
'ends_at' => 'datetime',
];
// Promo types
public const TYPE_BOGO = 'bogo';
public const TYPE_PERCENT_OFF = 'percent_off';
public const TYPE_BUNDLE = 'bundle';
public const TYPE_SET_PRICE = 'set_price';
public const TYPE_BUY_X_GET_Y = 'buy_x_get_y';
// Statuses
public const STATUS_DRAFT = 'draft';
public const STATUS_ACTIVE = 'active';
public const STATUS_EXPIRED = 'expired';
public const STATUS_CANCELLED = 'cancelled';
// -------------------------------------------------------------------------
// Relationships
// -------------------------------------------------------------------------
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function brand(): BelongsTo
{
return $this->belongsTo(Brand::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
// -------------------------------------------------------------------------
// Scopes
// -------------------------------------------------------------------------
public function scopeForBusiness($query, int $businessId)
{
return $query->where('business_id', $businessId);
}
public function scopeForStore($query, string $storeExternalId)
{
return $query->where('store_external_id', $storeExternalId);
}
public function scopeDraft($query)
{
return $query->where('status', self::STATUS_DRAFT);
}
public function scopeActive($query)
{
return $query->where('status', self::STATUS_ACTIVE);
}
public function scopeExpired($query)
{
return $query->where('status', self::STATUS_EXPIRED);
}
public function scopeCurrentlyActive($query)
{
return $query->where('status', self::STATUS_ACTIVE)
->where(function ($q) {
$q->whereNull('starts_at')->orWhere('starts_at', '<=', now());
})
->where(function ($q) {
$q->whereNull('ends_at')->orWhere('ends_at', '>=', now());
});
}
// -------------------------------------------------------------------------
// Accessors
// -------------------------------------------------------------------------
/**
* Get human-readable promo type
*/
public function getTypeDisplayAttribute(): string
{
return match ($this->type) {
self::TYPE_BOGO => 'Buy One Get One',
self::TYPE_PERCENT_OFF => 'Percentage Off',
self::TYPE_BUNDLE => 'Bundle Deal',
self::TYPE_SET_PRICE => 'Set Price',
self::TYPE_BUY_X_GET_Y => 'Buy X Get Y',
default => ucfirst(str_replace('_', ' ', $this->type)),
};
}
/**
* Get status badge color
*/
public function getStatusColorAttribute(): string
{
return match ($this->status) {
self::STATUS_DRAFT => 'warning',
self::STATUS_ACTIVE => 'success',
self::STATUS_EXPIRED => 'neutral',
self::STATUS_CANCELLED => 'error',
default => 'neutral',
};
}
/**
* Check if promo is currently running
*/
public function getIsRunningAttribute(): bool
{
if ($this->status !== self::STATUS_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;
}
/**
* Get discount value from config
*/
public function getDiscountValueAttribute(): ?float
{
return $this->config['discount_value'] ?? null;
}
/**
* Get target products from config
*/
public function getTargetProductsAttribute(): array
{
return $this->config['target_products'] ?? [];
}
/**
* Get required quantity from config
*/
public function getRequiredQuantityAttribute(): ?int
{
return $this->config['required_quantity'] ?? null;
}
// -------------------------------------------------------------------------
// Methods
// -------------------------------------------------------------------------
/**
* Activate the promo
*/
public function activate(): bool
{
$this->status = self::STATUS_ACTIVE;
return $this->save();
}
/**
* Cancel the promo
*/
public function cancel(): bool
{
$this->status = self::STATUS_CANCELLED;
return $this->save();
}
/**
* Mark as expired
*/
public function expire(): bool
{
$this->status = self::STATUS_EXPIRED;
return $this->save();
}
/**
* Get available promo types
*/
public static function getTypes(): array
{
return [
self::TYPE_BOGO => 'Buy One Get One',
self::TYPE_PERCENT_OFF => 'Percentage Off',
self::TYPE_BUNDLE => 'Bundle Deal',
self::TYPE_SET_PRICE => 'Set Price',
self::TYPE_BUY_X_GET_Y => 'Buy X Get Y',
];
}
/**
* Get available statuses
*/
public static function getStatuses(): array
{
return [
self::STATUS_DRAFT => 'Draft',
self::STATUS_ACTIVE => 'Active',
self::STATUS_EXPIRED => 'Expired',
self::STATUS_CANCELLED => 'Cancelled',
];
}
}

View File

@@ -151,4 +151,45 @@ class OrderItem extends Model implements Auditable
{
return $this->pre_delivery_status !== 'rejected';
}
/**
* Get human-readable quantity display string
* e.g., "2 Cases (24 Units)" or "24 Units" if no case info
*
* @param int|null $qty Override quantity (useful for picked/delivered display)
*/
public function getQuantityDisplay(?int $qty = null): string
{
$quantity = $qty ?? $this->quantity;
$unitsPerCase = $this->product?->units_per_case;
if ($unitsPerCase && $unitsPerCase > 1) {
$totalUnits = $quantity * $unitsPerCase;
return sprintf(
'%d %s (%d Units)',
$quantity,
$quantity === 1 ? 'Case' : 'Cases',
$totalUnits
);
}
return sprintf('%d %s', $quantity, $quantity === 1 ? 'Unit' : 'Units');
}
/**
* Get the display quantity (picked if available, otherwise ordered)
*/
public function getEffectiveQuantity(): int
{
return $this->picked_qty > 0 ? $this->picked_qty : $this->quantity;
}
/**
* Get display for effective quantity (picked or ordered)
*/
public function getEffectiveQuantityDisplay(): string
{
return $this->getQuantityDisplay($this->getEffectiveQuantity());
}
}

View File

@@ -490,6 +490,11 @@ class Product extends Model implements Auditable
return $query->where('brand_id', $brandId);
}
public function scopeForBusiness($query, Business $business)
{
return $query->whereHas('brand', fn ($q) => $q->where('business_id', $business->id));
}
public function scopeForDepartment($query, Department $department)
{
return $query->where('department_id', $department->id);

View File

@@ -1084,4 +1084,39 @@ class User extends Authenticatable implements FilamentUser
{
return $this->hasMany(Business::class, 'owner_user_id');
}
// -------------------------------------------------------------------------
// Marketing Portal Methods
// -------------------------------------------------------------------------
/**
* Check if user is a Marketing Portal user for a given business.
*
* Marketing Portal access is granted when:
* - User belongs to the business with contact_type = 'marketing_portal'
*/
public function isMarketingPortalUser(Business $business): bool
{
// Super admins have access to all portals
if ($this->isSuperAdmin()) {
return true;
}
// Check contact_type on business_user pivot
$pivot = $this->businesses()
->where('business_id', $business->id)
->first()?->pivot;
return $pivot && $pivot->contact_type === 'marketing_portal';
}
/**
* Get businesses where user has marketing portal access.
*/
public function getMarketingPortalBusinesses(): \Illuminate\Database\Eloquent\Collection
{
return $this->businesses()
->wherePivot('contact_type', 'marketing_portal')
->get();
}
}

View File

@@ -0,0 +1,149 @@
<?php
namespace App\Policies;
use App\Models\Business;
use App\Models\Marketing\MarketingCampaign;
use App\Models\User;
class MarketingCampaignPolicy
{
/**
* Determine if the user can view any campaigns for the business
*/
public function viewAny(User $user, ?Business $business = null): bool
{
if ($user->hasRole('Super Admin')) {
return true;
}
if (! $business) {
return false;
}
return $user->businesses->contains($business->id)
|| $user->isMarketingPortalUser($business);
}
/**
* Determine if the user can view a specific campaign
*/
public function view(User $user, MarketingCampaign $campaign, Business $business): bool
{
if ($user->hasRole('Super Admin')) {
return true;
}
// Campaign must belong to this business
if ($campaign->business_id !== $business->id) {
return false;
}
return $user->businesses->contains($business->id)
|| $user->isMarketingPortalUser($business);
}
/**
* Determine if the user can create campaigns
*/
public function create(User $user, Business $business): bool
{
if ($user->hasRole('Super Admin')) {
return true;
}
return $user->businesses->contains($business->id)
|| $user->isMarketingPortalUser($business);
}
/**
* Determine if the user can update the campaign
*/
public function update(User $user, MarketingCampaign $campaign, Business $business): bool
{
if ($user->hasRole('Super Admin')) {
return true;
}
// Campaign must belong to this business
if ($campaign->business_id !== $business->id) {
return false;
}
// Can only update drafts and scheduled campaigns
if (! in_array($campaign->status, ['draft', 'scheduled'])) {
return false;
}
return $user->businesses->contains($business->id)
|| $user->isMarketingPortalUser($business);
}
/**
* Determine if the user can delete the campaign
*/
public function delete(User $user, MarketingCampaign $campaign, Business $business): bool
{
if ($user->hasRole('Super Admin')) {
return true;
}
// Campaign must belong to this business
if ($campaign->business_id !== $business->id) {
return false;
}
// Can only delete drafts
if ($campaign->status !== 'draft') {
return false;
}
return $user->businesses->contains($business->id);
}
/**
* Determine if the user can send the campaign
*/
public function send(User $user, MarketingCampaign $campaign, Business $business): bool
{
if ($user->hasRole('Super Admin')) {
return true;
}
// Campaign must belong to this business
if ($campaign->business_id !== $business->id) {
return false;
}
// Can only send drafts and scheduled campaigns
if (! in_array($campaign->status, ['draft', 'scheduled'])) {
return false;
}
return $user->businesses->contains($business->id)
|| $user->isMarketingPortalUser($business);
}
/**
* Determine if the user can cancel the campaign
*/
public function cancel(User $user, MarketingCampaign $campaign, Business $business): bool
{
if ($user->hasRole('Super Admin')) {
return true;
}
// Campaign must belong to this business
if ($campaign->business_id !== $business->id) {
return false;
}
// Can only cancel drafts and scheduled campaigns
if (! in_array($campaign->status, ['draft', 'scheduled'])) {
return false;
}
return $user->businesses->contains($business->id)
|| $user->isMarketingPortalUser($business);
}
}

View File

@@ -0,0 +1,127 @@
<?php
namespace App\Policies;
use App\Models\Business;
use App\Models\Marketing\MarketingPromo;
use App\Models\User;
class MarketingPromoPolicy
{
/**
* Determine if the user can view any promos for the business
*/
public function viewAny(User $user, ?Business $business = null): bool
{
if ($user->hasRole('Super Admin')) {
return true;
}
if (! $business) {
return false;
}
return $user->businesses->contains($business->id)
|| $user->isMarketingPortalUser($business);
}
/**
* Determine if the user can view a specific promo
*/
public function view(User $user, MarketingPromo $promo, Business $business): bool
{
if ($user->hasRole('Super Admin')) {
return true;
}
// Promo must belong to this business
if ($promo->business_id !== $business->id) {
return false;
}
return $user->businesses->contains($business->id)
|| $user->isMarketingPortalUser($business);
}
/**
* Determine if the user can create promos
*
* Portal users can only view - they can't create promos
*/
public function create(User $user, Business $business): bool
{
if ($user->hasRole('Super Admin')) {
return true;
}
// Portal users are view-only for promos
if ($user->isMarketingPortalUser($business) && ! $user->businesses->contains($business->id)) {
return false;
}
return $user->businesses->contains($business->id);
}
/**
* Determine if the user can update the promo
*
* Portal users can only view - they can't update promos
*/
public function update(User $user, MarketingPromo $promo, Business $business): bool
{
if ($user->hasRole('Super Admin')) {
return true;
}
// Promo must belong to this business
if ($promo->business_id !== $business->id) {
return false;
}
// Portal users are view-only for promos
if ($user->isMarketingPortalUser($business) && ! $user->businesses->contains($business->id)) {
return false;
}
return $user->businesses->contains($business->id);
}
/**
* Determine if the user can delete the promo
*
* Portal users can only view - they can't delete promos
*/
public function delete(User $user, MarketingPromo $promo, Business $business): bool
{
if ($user->hasRole('Super Admin')) {
return true;
}
// Promo must belong to this business
if ($promo->business_id !== $business->id) {
return false;
}
return $user->businesses->contains($business->id);
}
/**
* Determine if the user can launch a campaign from this promo
*
* Portal users CAN launch campaigns from promos
*/
public function launchCampaign(User $user, MarketingPromo $promo, Business $business): bool
{
if ($user->hasRole('Super Admin')) {
return true;
}
// Promo must belong to this business
if ($promo->business_id !== $business->id) {
return false;
}
return $user->businesses->contains($business->id)
|| $user->isMarketingPortalUser($business);
}
}

View File

@@ -109,6 +109,7 @@ class AppServiceProvider extends ServiceProvider
$versionData = cache()->remember('app.version_data', now()->addSeconds(5), function () {
$version = 'dev';
$commit = 'unknown';
$buildDate = null;
// For Docker: read from version.env (injected at build time)
$versionFile = base_path('version.env');
@@ -117,6 +118,7 @@ class AppServiceProvider extends ServiceProvider
$data = parse_ini_file($versionFile);
$version = $data['VERSION'] ?? 'dev';
$commit = $data['COMMIT'] ?? 'unknown';
$buildDate = $data['BUILD_DATE'] ?? null;
}
// For local dev: read from git directly (but cached for 5 seconds)
// Check for .git (directory for regular repos, file for worktrees)
@@ -128,6 +130,13 @@ class AppServiceProvider extends ServiceProvider
// Only proceed if we successfully got a commit SHA
if ($commit !== '' && $commit !== 'unknown') {
// Get commit date for local dev
$dateCommand = sprintf('cd %s && git log -1 --format=%%ci 2>/dev/null', escapeshellarg(base_path()));
$commitDate = trim(shell_exec($dateCommand) ?: '');
if ($commitDate) {
$buildDate = date('M j, g:ia', strtotime($commitDate));
}
// Check for uncommitted changes (dirty working directory)
$diffCommand = sprintf('cd %s && git diff --quiet 2>/dev/null; echo $?', escapeshellarg(base_path()));
$cachedCommand = sprintf('cd %s && git diff --cached --quiet 2>/dev/null; echo $?', escapeshellarg(base_path()));
@@ -147,17 +156,19 @@ class AppServiceProvider extends ServiceProvider
return [
'version' => $version,
'commit' => $commit,
'buildDate' => $buildDate,
];
});
} catch (\Exception $e) {
// If cache fails (e.g., Redis not ready), calculate version without caching
$versionData = ['version' => 'dev', 'commit' => 'unknown'];
$versionData = ['version' => 'dev', 'commit' => 'unknown', 'buildDate' => null];
$versionFile = base_path('version.env');
if (File::exists($versionFile)) {
$data = parse_ini_file($versionFile);
$versionData['version'] = $data['VERSION'] ?? 'dev';
$versionData['commit'] = $data['COMMIT'] ?? 'unknown';
$versionData['buildDate'] = $data['BUILD_DATE'] ?? null;
}
}
@@ -165,6 +176,7 @@ class AppServiceProvider extends ServiceProvider
$view->with([
'appVersion' => $versionData['version'],
'appCommit' => $versionData['commit'],
'appBuildDate' => $versionData['buildDate'],
'appVersionFull' => "{$versionData['version']} (sha-{$versionData['commit']})",
]);
});

View File

@@ -2,7 +2,11 @@
namespace App\Providers;
use App\Models\Marketing\MarketingCampaign;
use App\Models\Marketing\MarketingPromo;
use App\Models\Product;
use App\Policies\MarketingCampaignPolicy;
use App\Policies\MarketingPromoPolicy;
use App\Policies\ProductPolicy;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
@@ -15,6 +19,8 @@ class AuthServiceProvider extends ServiceProvider
*/
protected $policies = [
Product::class => ProductPolicy::class,
MarketingCampaign::class => MarketingCampaignPolicy::class,
MarketingPromo::class => MarketingPromoPolicy::class,
];
/**

View File

@@ -260,10 +260,10 @@ class BrandVoicePrompt
* AI generation MUST respect these limits.
*/
public const CHARACTER_LIMITS = [
'tagline' => ['min' => 30, 'max' => 45, 'label' => 'Tagline'],
'short_description' => ['min' => 100, 'max' => 150, 'label' => 'Short Description'],
'description' => ['min' => 100, 'max' => 150, 'label' => 'Short Description'], // Alias
'long_description' => ['min' => 400, 'max' => 500, 'label' => 'Long Description'],
'tagline' => ['min' => null, 'max' => 255, 'label' => 'Tagline'],
'short_description' => ['min' => null, 'max' => 1000, 'label' => 'Short Description'],
'description' => ['min' => null, 'max' => 1000, 'label' => 'Short Description'], // Alias
'long_description' => ['min' => null, 'max' => 5000, 'label' => 'Long Description'],
'brand_announcement' => ['min' => 400, 'max' => 500, 'label' => 'Brand Announcement'],
'seo_title' => ['min' => 60, 'max' => 70, 'label' => 'SEO Title'],
'seo_description' => ['min' => 150, 'max' => 160, 'label' => 'SEO Description'],
@@ -803,6 +803,11 @@ class BrandVoicePrompt
return '';
}
// If no min is set, only enforce max
if ($limits['min'] === null) {
return "CHARACTER LIMIT: Output should not exceed {$limits['max']} characters.";
}
return "STRICT CHARACTER LIMIT: Output MUST be between {$limits['min']}-{$limits['max']} characters. Do NOT output fewer than {$limits['min']} or more than {$limits['max']} characters.";
}

View File

@@ -8,6 +8,7 @@ use App\Models\Accounting\ArCustomer;
use App\Models\Accounting\ArInvoice;
use App\Models\Business;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
/**
* AR Service - Core AR operations and credit enforcement.
@@ -316,40 +317,43 @@ class ArService
public function getArSummary(Business $business, ?array $businessIds = null): array
{
$businessIds = $businessIds ?? $this->getBusinessIdsWithChildren($business);
$cacheKey = 'ar_summary_'.implode('_', $businessIds);
$totalAr = ArInvoice::whereIn('business_id', $businessIds)
->whereNotIn('status', [ArInvoice::STATUS_PAID, ArInvoice::STATUS_VOID])
->where('balance_due', '>', 0)
->sum('balance_due');
return Cache::remember($cacheKey, now()->addHour(), function () use ($businessIds) {
$totalAr = ArInvoice::whereIn('business_id', $businessIds)
->whereNotIn('status', [ArInvoice::STATUS_PAID, ArInvoice::STATUS_VOID])
->where('balance_due', '>', 0)
->sum('balance_due');
$totalPastDue = ArInvoice::whereIn('business_id', $businessIds)
->whereNotIn('status', [ArInvoice::STATUS_PAID, ArInvoice::STATUS_VOID])
->where('balance_due', '>', 0)
->where('due_date', '<', now())
->sum('balance_due');
$totalPastDue = ArInvoice::whereIn('business_id', $businessIds)
->whereNotIn('status', [ArInvoice::STATUS_PAID, ArInvoice::STATUS_VOID])
->where('balance_due', '>', 0)
->where('due_date', '<', now())
->sum('balance_due');
$atRiskCount = ArCustomer::whereIn('business_id', $businessIds)
->where('is_active', true)
->where(function ($query) {
$query->where('on_credit_hold', true)
->orWhereHas('invoices', function ($q) {
$q->whereNotIn('status', [ArInvoice::STATUS_PAID, ArInvoice::STATUS_VOID])
->where('balance_due', '>', 0)
->where('due_date', '<', now());
});
})
->count();
$atRiskCount = ArCustomer::whereIn('business_id', $businessIds)
->where('is_active', true)
->where(function ($query) {
$query->where('on_credit_hold', true)
->orWhereHas('invoices', function ($q) {
$q->whereNotIn('status', [ArInvoice::STATUS_PAID, ArInvoice::STATUS_VOID])
->where('balance_due', '>', 0)
->where('due_date', '<', now());
});
})
->count();
$onHoldCount = ArCustomer::whereIn('business_id', $businessIds)
->where('on_credit_hold', true)
->count();
$onHoldCount = ArCustomer::whereIn('business_id', $businessIds)
->where('on_credit_hold', true)
->count();
return [
'total_ar' => (float) $totalAr,
'total_past_due' => (float) $totalPastDue,
'at_risk_count' => $atRiskCount,
'on_hold_count' => $onHoldCount,
];
return [
'total_ar' => (float) $totalAr,
'total_past_due' => (float) $totalPastDue,
'at_risk_count' => $atRiskCount,
'on_hold_count' => $onHoldCount,
];
});
}
/**
@@ -360,39 +364,42 @@ class ArService
public function getTopArAccounts(Business $business, int $limit = 5, ?array $businessIds = null): Collection
{
$businessIds = $businessIds ?? $this->getBusinessIdsWithChildren($business);
$cacheKey = 'ar_top_accounts_'.implode('_', $businessIds).'_'.$limit;
return ArCustomer::whereIn('ar_customers.business_id', $businessIds)
->where('is_active', true)
->whereHas('invoices', function ($q) {
$q->whereNotIn('status', [ArInvoice::STATUS_PAID, ArInvoice::STATUS_VOID])
->where('balance_due', '>', 0);
})
->with('business')
->get()
->map(function ($customer) {
$balance = $customer->invoices()
->whereNotIn('status', [ArInvoice::STATUS_PAID, ArInvoice::STATUS_VOID])
->where('balance_due', '>', 0)
->sum('balance_due');
return Cache::remember($cacheKey, now()->addHour(), function () use ($businessIds, $limit) {
return ArCustomer::whereIn('ar_customers.business_id', $businessIds)
->where('is_active', true)
->whereHas('invoices', function ($q) {
$q->whereNotIn('status', [ArInvoice::STATUS_PAID, ArInvoice::STATUS_VOID])
->where('balance_due', '>', 0);
})
->with('business')
->get()
->map(function ($customer) {
$balance = $customer->invoices()
->whereNotIn('status', [ArInvoice::STATUS_PAID, ArInvoice::STATUS_VOID])
->where('balance_due', '>', 0)
->sum('balance_due');
$pastDue = $customer->invoices()
->whereNotIn('status', [ArInvoice::STATUS_PAID, ArInvoice::STATUS_VOID])
->where('balance_due', '>', 0)
->where('due_date', '<', now())
->sum('balance_due');
$pastDue = $customer->invoices()
->whereNotIn('status', [ArInvoice::STATUS_PAID, ArInvoice::STATUS_VOID])
->where('balance_due', '>', 0)
->where('due_date', '<', now())
->sum('balance_due');
return [
'customer' => $customer,
'business' => $customer->business,
'balance' => (float) $balance,
'past_due' => (float) $pastDue,
'credit_status' => $customer->credit_status ?? ArCustomer::CREDIT_STATUS_GOOD,
'on_credit_hold' => $customer->on_credit_hold ?? false,
];
})
->sortByDesc('balance')
->take($limit)
->values();
return [
'customer' => $customer,
'business' => $customer->business,
'balance' => (float) $balance,
'past_due' => (float) $pastDue,
'credit_status' => $customer->credit_status ?? ArCustomer::CREDIT_STATUS_GOOD,
'on_credit_hold' => $customer->on_credit_hold ?? false,
];
})
->sortByDesc('balance')
->take($limit)
->values();
});
}
/**

View File

@@ -13,6 +13,7 @@ use App\Models\Business;
use App\Models\Department;
use Carbon\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class BudgetService
@@ -244,52 +245,56 @@ class BudgetService
*/
public function getBudgetSummary(Budget $budget): array
{
$lines = $budget->lines()->with(['department', 'glAccount'])->get();
$cacheKey = 'budget_summary_'.$budget->id;
$totalBudget = $lines->sum('amount');
return Cache::remember($cacheKey, now()->addHour(), function () use ($budget) {
$lines = $budget->lines()->with(['department', 'glAccount'])->get();
// Get all actuals for the budget
$actuals = $this->getActualsForBudget($budget);
$totalActual = $actuals->sum('actual_amount');
$totalBudget = $lines->sum('amount');
$varianceAmount = $totalBudget - $totalActual;
$variancePercent = $totalBudget > 0
? round(($varianceAmount / $totalBudget) * 100, 2)
: 0;
// Get all actuals for the budget
$actuals = $this->getActualsForBudget($budget);
$totalActual = $actuals->sum('actual_amount');
// Group by department for summary
$byDepartment = $actuals->groupBy('department_id')
->map(function ($items, $deptId) {
return [
'department_id' => $deptId,
'department_name' => $items->first()['department_name'],
'budget' => $items->sum('budget_amount'),
'actual' => $items->sum('actual_amount'),
];
})->values();
$varianceAmount = $totalBudget - $totalActual;
$variancePercent = $totalBudget > 0
? round(($varianceAmount / $totalBudget) * 100, 2)
: 0;
// Group by account for summary
$byAccount = $actuals->groupBy('gl_account_id')
->map(function ($items, $accountId) {
$first = $items->first();
// Group by department for summary
$byDepartment = $actuals->groupBy('department_id')
->map(function ($items, $deptId) {
return [
'department_id' => $deptId,
'department_name' => $items->first()['department_name'],
'budget' => $items->sum('budget_amount'),
'actual' => $items->sum('actual_amount'),
];
})->values();
return [
'account_id' => $accountId,
'account_name' => $first['account_number'].' - '.$first['account_name'],
'budget' => $items->sum('budget_amount'),
'actual' => $items->sum('actual_amount'),
];
})->values();
// Group by account for summary
$byAccount = $actuals->groupBy('gl_account_id')
->map(function ($items, $accountId) {
$first = $items->first();
return [
'total_budget' => $totalBudget,
'total_actual' => $totalActual,
'variance_amount' => $varianceAmount,
'variance_percent' => $variancePercent,
'line_count' => $lines->count(),
'by_department' => $byDepartment,
'by_account' => $byAccount,
];
return [
'account_id' => $accountId,
'account_name' => $first['account_number'].' - '.$first['account_name'],
'budget' => $items->sum('budget_amount'),
'actual' => $items->sum('actual_amount'),
];
})->values();
return [
'total_budget' => $totalBudget,
'total_actual' => $totalActual,
'variance_amount' => $varianceAmount,
'variance_percent' => $variancePercent,
'line_count' => $lines->count(),
'by_department' => $byDepartment,
'by_account' => $byAccount,
];
});
}
/**

View File

@@ -11,6 +11,7 @@ use App\Models\Accounting\FinancialActivity;
use App\Models\Business;
use Carbon\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
class CustomerFinancialService
{
@@ -26,53 +27,57 @@ class CustomerFinancialService
? $business->divisions()->pluck('id')->push($business->id)->toArray()
: [$business->id];
// Get all open invoices
$openInvoices = ArInvoice::where('ar_customer_id', $customer->id)
->whereIn('business_id', $businessIds)
->whereIn('status', ['open', 'partial'])
->get();
$cacheKey = 'customer_financial_summary_'.$customer->id.'_'.implode('_', $businessIds);
$totalOpenAr = $openInvoices->sum('balance_due');
$now = now();
return Cache::remember($cacheKey, now()->addHour(), function () use ($customer, $businessIds) {
// Get all open invoices
$openInvoices = ArInvoice::where('ar_customer_id', $customer->id)
->whereIn('business_id', $businessIds)
->whereIn('status', ['open', 'partial'])
->get();
// Calculate aging buckets
$aging = $this->calculateAgingBuckets($openInvoices, $now);
$totalOpenAr = $openInvoices->sum('balance_due');
$now = now();
// Past due calculations
$pastDueInvoices = $openInvoices->filter(fn ($inv) => $inv->due_date->lt($now));
$pastDueTotal = $pastDueInvoices->sum('balance_due');
// Calculate aging buckets
$aging = $this->calculateAgingBuckets($openInvoices, $now);
// Credit status
$creditLimit = $customer->credit_limit ?? 0;
$creditUsed = $totalOpenAr;
$overLimitAmount = max(0, $creditUsed - $creditLimit);
$creditStatus = $this->determineCreditStatus($customer, $creditUsed, $pastDueTotal);
// Past due calculations
$pastDueInvoices = $openInvoices->filter(fn ($inv) => $inv->due_date->lt($now));
$pastDueTotal = $pastDueInvoices->sum('balance_due');
// Last payment
$lastPayment = ArPayment::whereHas('invoice', fn ($q) => $q->where('ar_customer_id', $customer->id))
->whereIn('business_id', $businessIds)
->orderByDesc('payment_date')
->first();
// Credit status
$creditLimit = $customer->credit_limit ?? 0;
$creditUsed = $totalOpenAr;
$overLimitAmount = max(0, $creditUsed - $creditLimit);
$creditStatus = $this->determineCreditStatus($customer, $creditUsed, $pastDueTotal);
return [
'customer' => $customer,
'total_open_ar' => $totalOpenAr,
'past_due_total' => $pastDueTotal,
'aging' => $aging,
'credit_limit' => $creditLimit,
'credit_used' => $creditUsed,
'credit_available' => max(0, $creditLimit - $creditUsed),
'over_limit_amount' => $overLimitAmount,
'credit_status' => $creditStatus,
'on_credit_hold' => $customer->on_credit_hold ?? false,
'hold_reason' => $customer->hold_reason ?? null,
'last_payment_date' => $lastPayment?->payment_date,
'last_payment_amount' => $lastPayment?->amount ?? 0,
'open_invoice_count' => $openInvoices->count(),
'past_due_count' => $pastDueInvoices->count(),
'highest_aging_bucket' => $this->getHighestAgingBucket($aging),
'avg_days_to_pay' => $this->calculateAvgDaysToPay($customer, $businessIds),
];
// Last payment
$lastPayment = ArPayment::whereHas('invoice', fn ($q) => $q->where('ar_customer_id', $customer->id))
->whereIn('business_id', $businessIds)
->orderByDesc('payment_date')
->first();
return [
'customer' => $customer,
'total_open_ar' => $totalOpenAr,
'past_due_total' => $pastDueTotal,
'aging' => $aging,
'credit_limit' => $creditLimit,
'credit_used' => $creditUsed,
'credit_available' => max(0, $creditLimit - $creditUsed),
'over_limit_amount' => $overLimitAmount,
'credit_status' => $creditStatus,
'on_credit_hold' => $customer->on_credit_hold ?? false,
'hold_reason' => $customer->hold_reason ?? null,
'last_payment_date' => $lastPayment?->payment_date,
'last_payment_amount' => $lastPayment?->amount ?? 0,
'open_invoice_count' => $openInvoices->count(),
'past_due_count' => $pastDueInvoices->count(),
'highest_aging_bucket' => $this->getHighestAgingBucket($aging),
'avg_days_to_pay' => $this->calculateAvgDaysToPay($customer, $businessIds),
];
});
}
/**

View File

@@ -9,6 +9,7 @@ use App\Models\Accounting\ArCustomer;
use App\Models\Accounting\ArInvoice;
use App\Models\Business;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
/**
* Service for financial analytics and reporting.
@@ -25,50 +26,54 @@ class FinanceAnalyticsService
*/
public function getAPAging(Business $business, ?array $businessIds = null): array
{
$today = now()->startOfDay();
$businessIds = $businessIds ?? $this->getBusinessIdsWithChildren($business);
$cacheKey = 'finance_ap_aging_'.implode('_', $businessIds);
$bills = ApBill::whereIn('business_id', $businessIds)
->whereIn('status', [ApBill::STATUS_APPROVED, ApBill::STATUS_PARTIAL])
->with(['vendor', 'business'])
->get();
return Cache::remember($cacheKey, now()->addHour(), function () use ($businessIds) {
$today = now()->startOfDay();
$buckets = [
'current' => ['label' => '0-30 days', 'min' => 0, 'max' => 30, 'amount' => 0, 'count' => 0],
'bucket_31_60' => ['label' => '31-60 days', 'min' => 31, 'max' => 60, 'amount' => 0, 'count' => 0],
'bucket_61_90' => ['label' => '61-90 days', 'min' => 61, 'max' => 90, 'amount' => 0, 'count' => 0],
'over_90' => ['label' => '90+ days', 'min' => 91, 'max' => 9999, 'amount' => 0, 'count' => 0],
];
$bills = ApBill::whereIn('business_id', $businessIds)
->whereIn('status', [ApBill::STATUS_APPROVED, ApBill::STATUS_PARTIAL])
->with(['vendor', 'business'])
->get();
$overdueBills = collect();
$buckets = [
'current' => ['label' => '0-30 days', 'min' => 0, 'max' => 30, 'amount' => 0, 'count' => 0],
'bucket_31_60' => ['label' => '31-60 days', 'min' => 31, 'max' => 60, 'amount' => 0, 'count' => 0],
'bucket_61_90' => ['label' => '61-90 days', 'min' => 61, 'max' => 90, 'amount' => 0, 'count' => 0],
'over_90' => ['label' => '90+ days', 'min' => 91, 'max' => 9999, 'amount' => 0, 'count' => 0],
];
foreach ($bills as $bill) {
$daysOld = $bill->due_date ? $today->diffInDays($bill->due_date, false) * -1 : 0;
$overdueBills = collect();
if ($daysOld <= 30) {
$buckets['current']['amount'] += $bill->balance_due;
$buckets['current']['count']++;
} elseif ($daysOld <= 60) {
$buckets['bucket_31_60']['amount'] += $bill->balance_due;
$buckets['bucket_31_60']['count']++;
} elseif ($daysOld <= 90) {
$buckets['bucket_61_90']['amount'] += $bill->balance_due;
$buckets['bucket_61_90']['count']++;
} else {
$buckets['over_90']['amount'] += $bill->balance_due;
$buckets['over_90']['count']++;
foreach ($bills as $bill) {
$daysOld = $bill->due_date ? $today->diffInDays($bill->due_date, false) * -1 : 0;
if ($daysOld <= 30) {
$buckets['current']['amount'] += $bill->balance_due;
$buckets['current']['count']++;
} elseif ($daysOld <= 60) {
$buckets['bucket_31_60']['amount'] += $bill->balance_due;
$buckets['bucket_31_60']['count']++;
} elseif ($daysOld <= 90) {
$buckets['bucket_61_90']['amount'] += $bill->balance_due;
$buckets['bucket_61_90']['count']++;
} else {
$buckets['over_90']['amount'] += $bill->balance_due;
$buckets['over_90']['count']++;
}
if ($bill->isOverdue()) {
$overdueBills->push($bill);
}
}
if ($bill->isOverdue()) {
$overdueBills->push($bill);
}
}
return [
'buckets' => $buckets,
'total' => array_sum(array_column($buckets, 'amount')),
'overdue_bills' => $overdueBills->sortBy('due_date'),
];
return [
'buckets' => $buckets,
'total' => array_sum(array_column($buckets, 'amount')),
'overdue_bills' => $overdueBills->sortBy('due_date'),
];
});
}
/**
@@ -78,24 +83,28 @@ class FinanceAnalyticsService
*/
public function getAPBreakdownByDivision(Business $business, ?array $businessIds = null): Collection
{
$children = Business::where('parent_id', $business->id)
->when($businessIds, fn ($q) => $q->whereIn('id', $businessIds))
->get();
$cacheKey = 'finance_ap_division_'.$business->id.'_'.($businessIds ? implode('_', $businessIds) : 'all');
return $children->map(function ($child) {
$bills = ApBill::where('business_id', $child->id)
->whereIn('status', [ApBill::STATUS_APPROVED, ApBill::STATUS_PARTIAL])
return Cache::remember($cacheKey, now()->addHour(), function () use ($business, $businessIds) {
$children = Business::where('parent_id', $business->id)
->when($businessIds, fn ($q) => $q->whereIn('id', $businessIds))
->get();
$overdue = $bills->filter(fn ($b) => $b->isOverdue());
return $children->map(function ($child) {
$bills = ApBill::where('business_id', $child->id)
->whereIn('status', [ApBill::STATUS_APPROVED, ApBill::STATUS_PARTIAL])
->get();
return [
'business' => $child,
'total_outstanding' => $bills->sum('balance_due'),
'overdue_amount' => $overdue->sum('balance_due'),
'bill_count' => $bills->count(),
'overdue_count' => $overdue->count(),
];
$overdue = $bills->filter(fn ($b) => $b->isOverdue());
return [
'business' => $child,
'total_outstanding' => $bills->sum('balance_due'),
'overdue_amount' => $overdue->sum('balance_due'),
'bill_count' => $bills->count(),
'overdue_count' => $overdue->count(),
];
});
});
}
@@ -107,22 +116,25 @@ class FinanceAnalyticsService
public function getAPBreakdownByVendor(Business $business, ?array $businessIds = null): Collection
{
$businessIds = $businessIds ?? $this->getBusinessIdsWithChildren($business);
$cacheKey = 'finance_ap_vendor_'.implode('_', $businessIds);
return ApBill::whereIn('business_id', $businessIds)
->whereIn('status', [ApBill::STATUS_APPROVED, ApBill::STATUS_PARTIAL])
->select('vendor_id')
->selectRaw('SUM(balance_due) as total_outstanding')
->selectRaw('COUNT(*) as bill_count')
->selectRaw('SUM(CASE WHEN due_date < NOW() THEN balance_due ELSE 0 END) as overdue_amount')
->groupBy('vendor_id')
->orderByDesc('total_outstanding')
->get()
->map(fn ($row) => [
'vendor' => ApVendor::find($row->vendor_id),
'total_outstanding' => (float) $row->total_outstanding,
'bill_count' => (int) $row->bill_count,
'overdue_amount' => (float) $row->overdue_amount,
]);
return Cache::remember($cacheKey, now()->addHour(), function () use ($businessIds) {
return ApBill::whereIn('business_id', $businessIds)
->whereIn('status', [ApBill::STATUS_APPROVED, ApBill::STATUS_PARTIAL])
->select('vendor_id')
->selectRaw('SUM(balance_due) as total_outstanding')
->selectRaw('COUNT(*) as bill_count')
->selectRaw('SUM(CASE WHEN due_date < NOW() THEN balance_due ELSE 0 END) as overdue_amount')
->groupBy('vendor_id')
->orderByDesc('total_outstanding')
->get()
->map(fn ($row) => [
'vendor' => ApVendor::find($row->vendor_id),
'total_outstanding' => (float) $row->total_outstanding,
'bill_count' => (int) $row->bill_count,
'overdue_amount' => (float) $row->overdue_amount,
]);
});
}
/**
@@ -133,49 +145,53 @@ class FinanceAnalyticsService
public function getCashForecast(Business $business, int $days = 30, ?array $businessIds = null): array
{
$businessIds = $businessIds ?? $this->getBusinessIdsWithChildren($business);
$startDate = now()->startOfDay();
$endDate = now()->addDays($days)->endOfDay();
$cacheKey = 'finance_cash_forecast_'.implode('_', $businessIds).'_'.$days;
$bills = ApBill::whereIn('business_id', $businessIds)
->whereIn('status', [ApBill::STATUS_APPROVED, ApBill::STATUS_PARTIAL])
->whereBetween('due_date', [$startDate, $endDate])
->with(['vendor', 'business'])
->orderBy('due_date')
->get();
return Cache::remember($cacheKey, now()->addHour(), function () use ($businessIds, $days) {
$startDate = now()->startOfDay();
$endDate = now()->addDays($days)->endOfDay();
$daily = [];
for ($i = 0; $i <= $days; $i++) {
$date = now()->addDays($i)->format('Y-m-d');
$daily[$date] = ['date' => $date, 'label' => now()->addDays($i)->format('M d'), 'amount' => 0, 'count' => 0];
}
$bills = ApBill::whereIn('business_id', $businessIds)
->whereIn('status', [ApBill::STATUS_APPROVED, ApBill::STATUS_PARTIAL])
->whereBetween('due_date', [$startDate, $endDate])
->with(['vendor', 'business'])
->orderBy('due_date')
->get();
foreach ($bills as $bill) {
$date = $bill->due_date->format('Y-m-d');
if (isset($daily[$date])) {
$daily[$date]['amount'] += $bill->balance_due;
$daily[$date]['count']++;
$daily = [];
for ($i = 0; $i <= $days; $i++) {
$date = now()->addDays($i)->format('Y-m-d');
$daily[$date] = ['date' => $date, 'label' => now()->addDays($i)->format('M d'), 'amount' => 0, 'count' => 0];
}
}
$byVendor = $bills->groupBy('vendor_id')->map(fn ($vendorBills) => [
'vendor' => $vendorBills->first()->vendor,
'total' => $vendorBills->sum('balance_due'),
'count' => $vendorBills->count(),
])->sortByDesc('total')->values();
foreach ($bills as $bill) {
$date = $bill->due_date->format('Y-m-d');
if (isset($daily[$date])) {
$daily[$date]['amount'] += $bill->balance_due;
$daily[$date]['count']++;
}
}
$byDivision = $bills->groupBy('business_id')->map(fn ($divisionBills) => [
'division' => $divisionBills->first()->business,
'total' => $divisionBills->sum('balance_due'),
'count' => $divisionBills->count(),
])->sortByDesc('total')->values();
$byVendor = $bills->groupBy('vendor_id')->map(fn ($vendorBills) => [
'vendor' => $vendorBills->first()->vendor,
'total' => $vendorBills->sum('balance_due'),
'count' => $vendorBills->count(),
])->sortByDesc('total')->values();
return [
'daily' => array_values($daily),
'by_vendor' => $byVendor,
'by_division' => $byDivision,
'total' => $bills->sum('balance_due'),
'bill_count' => $bills->count(),
];
$byDivision = $bills->groupBy('business_id')->map(fn ($divisionBills) => [
'division' => $divisionBills->first()->business,
'total' => $divisionBills->sum('balance_due'),
'count' => $divisionBills->count(),
])->sortByDesc('total')->values();
return [
'daily' => array_values($daily),
'by_vendor' => $byVendor,
'by_division' => $byDivision,
'total' => $bills->sum('balance_due'),
'bill_count' => $bills->count(),
];
});
}
/**
@@ -188,68 +204,72 @@ class FinanceAnalyticsService
return collect();
}
$children = Business::where('parent_id', $business->id)->get();
$yearStart = now()->startOfYear();
$cacheKey = 'finance_division_rollup_'.$business->id;
return $children->map(function ($child) use ($yearStart) {
// AP Metrics
$apOutstanding = ApBill::where('business_id', $child->id)
->whereIn('status', [ApBill::STATUS_APPROVED, ApBill::STATUS_PARTIAL])
->sum('balance_due');
return Cache::remember($cacheKey, now()->addHour(), function () use ($business) {
$children = Business::where('parent_id', $business->id)->get();
$yearStart = now()->startOfYear();
$apOverdue = ApBill::where('business_id', $child->id)
->whereIn('status', [ApBill::STATUS_APPROVED, ApBill::STATUS_PARTIAL])
->where('due_date', '<', now())
->sum('balance_due');
return $children->map(function ($child) use ($yearStart) {
// AP Metrics
$apOutstanding = ApBill::where('business_id', $child->id)
->whereIn('status', [ApBill::STATUS_APPROVED, ApBill::STATUS_PARTIAL])
->sum('balance_due');
$ytdPayments = ApPayment::where('business_id', $child->id)
->where('status', ApPayment::STATUS_COMPLETED)
->where('payment_date', '>=', $yearStart)
->sum('amount');
$apOverdue = ApBill::where('business_id', $child->id)
->whereIn('status', [ApBill::STATUS_APPROVED, ApBill::STATUS_PARTIAL])
->where('due_date', '<', now())
->sum('balance_due');
$pendingApproval = ApBill::where('business_id', $child->id)
->whereIn('status', [ApBill::STATUS_DRAFT, ApBill::STATUS_PENDING])
->count();
$ytdPayments = ApPayment::where('business_id', $child->id)
->where('status', ApPayment::STATUS_COMPLETED)
->where('payment_date', '>=', $yearStart)
->sum('amount');
// AR Metrics
$arTotal = ArInvoice::where('business_id', $child->id)
->whereIn('status', [ArInvoice::STATUS_SENT, ArInvoice::STATUS_PARTIAL, ArInvoice::STATUS_OVERDUE])
->sum('balance_due');
$pendingApproval = ApBill::where('business_id', $child->id)
->whereIn('status', [ApBill::STATUS_DRAFT, ApBill::STATUS_PENDING])
->count();
$arOverdue = ArInvoice::where('business_id', $child->id)
->whereIn('status', [ArInvoice::STATUS_SENT, ArInvoice::STATUS_PARTIAL, ArInvoice::STATUS_OVERDUE])
->where('due_date', '<', now())
->sum('balance_due');
// AR Metrics
$arTotal = ArInvoice::where('business_id', $child->id)
->whereIn('status', [ArInvoice::STATUS_SENT, ArInvoice::STATUS_PARTIAL, ArInvoice::STATUS_OVERDUE])
->sum('balance_due');
// Count at-risk customers (overdue or on credit hold)
$atRiskCustomers = ArCustomer::where('business_id', $child->id)
->where(function ($query) {
$query->where('on_credit_hold', true)
->orWhereHas('invoices', function ($q) {
$q->whereIn('status', [ArInvoice::STATUS_SENT, ArInvoice::STATUS_PARTIAL, ArInvoice::STATUS_OVERDUE])
->where('due_date', '<', now());
});
})
->count();
$arOverdue = ArInvoice::where('business_id', $child->id)
->whereIn('status', [ArInvoice::STATUS_SENT, ArInvoice::STATUS_PARTIAL, ArInvoice::STATUS_OVERDUE])
->where('due_date', '<', now())
->sum('balance_due');
$onHoldCustomers = ArCustomer::where('business_id', $child->id)
->where('on_credit_hold', true)
->count();
// Count at-risk customers (overdue or on credit hold)
$atRiskCustomers = ArCustomer::where('business_id', $child->id)
->where(function ($query) {
$query->where('on_credit_hold', true)
->orWhereHas('invoices', function ($q) {
$q->whereIn('status', [ArInvoice::STATUS_SENT, ArInvoice::STATUS_PARTIAL, ArInvoice::STATUS_OVERDUE])
->where('due_date', '<', now());
});
})
->count();
return [
'division' => $child,
// AP
'ap_outstanding' => (float) $apOutstanding,
'ap_overdue' => (float) $apOverdue,
'ytd_payments' => (float) $ytdPayments,
'pending_approval' => $pendingApproval,
// AR
'ar_total' => (float) $arTotal,
'ar_overdue' => (float) $arOverdue,
'ar_at_risk' => $atRiskCustomers,
'ar_on_hold' => $onHoldCustomers,
];
})->sortByDesc('ap_outstanding')->values();
$onHoldCustomers = ArCustomer::where('business_id', $child->id)
->where('on_credit_hold', true)
->count();
return [
'division' => $child,
// AP
'ap_outstanding' => (float) $apOutstanding,
'ap_overdue' => (float) $apOverdue,
'ytd_payments' => (float) $ytdPayments,
'pending_approval' => $pendingApproval,
// AR
'ar_total' => (float) $arTotal,
'ar_overdue' => (float) $arOverdue,
'ar_at_risk' => $atRiskCustomers,
'ar_on_hold' => $onHoldCustomers,
];
})->sortByDesc('ap_outstanding')->values();
});
}
/**
@@ -264,46 +284,50 @@ class FinanceAnalyticsService
$businessIds = $this->getBusinessIdsWithChildren($business);
}
$yearStart = now()->startOfYear();
$cacheKey = 'finance_vendor_spend_'.implode('_', $businessIds);
$ytdByVendor = ApPayment::whereIn('business_id', $businessIds)
->where('status', ApPayment::STATUS_COMPLETED)
->where('payment_date', '>=', $yearStart)
->select('vendor_id')
->selectRaw('SUM(amount) as total')
->groupBy('vendor_id')
->orderByDesc('total')
->limit(10)
->get()
->map(fn ($row) => ['vendor' => ApVendor::find($row->vendor_id), 'total' => (float) $row->total]);
return Cache::remember($cacheKey, now()->addHour(), function () use ($businessIds) {
$yearStart = now()->startOfYear();
$monthlyTrend = [];
for ($i = 11; $i >= 0; $i--) {
$month = now()->subMonths($i);
$total = ApPayment::whereIn('business_id', $businessIds)
$ytdByVendor = ApPayment::whereIn('business_id', $businessIds)
->where('status', ApPayment::STATUS_COMPLETED)
->whereBetween('payment_date', [$month->copy()->startOfMonth(), $month->copy()->endOfMonth()])
->where('payment_date', '>=', $yearStart)
->select('vendor_id')
->selectRaw('SUM(amount) as total')
->groupBy('vendor_id')
->orderByDesc('total')
->limit(10)
->get()
->map(fn ($row) => ['vendor' => ApVendor::find($row->vendor_id), 'total' => (float) $row->total]);
$monthlyTrend = [];
for ($i = 11; $i >= 0; $i--) {
$month = now()->subMonths($i);
$total = ApPayment::whereIn('business_id', $businessIds)
->where('status', ApPayment::STATUS_COMPLETED)
->whereBetween('payment_date', [$month->copy()->startOfMonth(), $month->copy()->endOfMonth()])
->sum('amount');
$monthlyTrend[] = ['month' => $month->format('M Y'), 'short' => $month->format('M'), 'amount' => (float) $total];
}
$mtdTotal = ApPayment::whereIn('business_id', $businessIds)
->where('status', ApPayment::STATUS_COMPLETED)
->where('payment_date', '>=', now()->startOfMonth())
->sum('amount');
$monthlyTrend[] = ['month' => $month->format('M Y'), 'short' => $month->format('M'), 'amount' => (float) $total];
}
$ytdTotal = ApPayment::whereIn('business_id', $businessIds)
->where('status', ApPayment::STATUS_COMPLETED)
->where('payment_date', '>=', $yearStart)
->sum('amount');
$mtdTotal = ApPayment::whereIn('business_id', $businessIds)
->where('status', ApPayment::STATUS_COMPLETED)
->where('payment_date', '>=', now()->startOfMonth())
->sum('amount');
$ytdTotal = ApPayment::whereIn('business_id', $businessIds)
->where('status', ApPayment::STATUS_COMPLETED)
->where('payment_date', '>=', $yearStart)
->sum('amount');
return [
'top_vendors' => $ytdByVendor,
'mtd_total' => (float) $mtdTotal,
'ytd_total' => (float) $ytdTotal,
'monthly_trend' => $monthlyTrend,
];
return [
'top_vendors' => $ytdByVendor,
'mtd_total' => (float) $mtdTotal,
'ytd_total' => (float) $ytdTotal,
'monthly_trend' => $monthlyTrend,
];
});
}
/**

View File

@@ -11,6 +11,7 @@ use App\Models\Accounting\FinancialActivity;
use App\Models\Business;
use Carbon\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
class VendorFinancialService
{
@@ -26,57 +27,61 @@ class VendorFinancialService
? $business->divisions()->pluck('id')->push($business->id)->toArray()
: [$business->id];
// Get all open bills
$openBills = ApBill::where('vendor_id', $vendor->id)
->whereIn('business_id', $businessIds)
->whereIn('status', ['open', 'partial'])
->get();
$cacheKey = 'vendor_financial_summary_'.$vendor->id.'_'.implode('_', $businessIds);
$totalOpenAp = $openBills->sum('balance_due');
$now = now();
// Calculate aging buckets
$aging = $this->calculateAgingBuckets($openBills, $now);
// Past due calculations
$pastDueBills = $openBills->filter(fn ($bill) => $bill->due_date->lt($now));
$pastDueTotal = $pastDueBills->sum('balance_due');
// Last payment
$lastPayment = ApPayment::whereHas('bill', fn ($q) => $q->where('vendor_id', $vendor->id))
->whereIn('business_id', $businessIds)
->orderByDesc('payment_date')
->first();
// Child businesses using this vendor
$childBusinessesUsing = [];
if ($includeChildren && $business->hasChildBusinesses()) {
$childBusinessesUsing = ApBill::where('vendor_id', $vendor->id)
return Cache::remember($cacheKey, now()->addHour(), function () use ($vendor, $business, $businessIds, $includeChildren) {
// Get all open bills
$openBills = ApBill::where('vendor_id', $vendor->id)
->whereIn('business_id', $businessIds)
->with('business')
->get()
->pluck('business')
->unique('id')
->values();
}
->whereIn('status', ['open', 'partial'])
->get();
return [
'vendor' => $vendor,
'total_open_ap' => $totalOpenAp,
'past_due_total' => $pastDueTotal,
'aging' => $aging,
'open_bill_count' => $openBills->count(),
'past_due_count' => $pastDueBills->count(),
'last_payment_date' => $lastPayment?->payment_date,
'last_payment_amount' => $lastPayment?->amount ?? 0,
'highest_aging_bucket' => $this->getHighestAgingBucket($aging),
'avg_payment_days' => $this->calculateAvgPaymentDays($vendor, $businessIds),
'child_businesses_using' => $childBusinessesUsing,
'ytd_paid' => $this->getYtdPaid($vendor, $businessIds),
'total_bills_all_time' => ApBill::where('vendor_id', $vendor->id)
$totalOpenAp = $openBills->sum('balance_due');
$now = now();
// Calculate aging buckets
$aging = $this->calculateAgingBuckets($openBills, $now);
// Past due calculations
$pastDueBills = $openBills->filter(fn ($bill) => $bill->due_date->lt($now));
$pastDueTotal = $pastDueBills->sum('balance_due');
// Last payment
$lastPayment = ApPayment::whereHas('bill', fn ($q) => $q->where('vendor_id', $vendor->id))
->whereIn('business_id', $businessIds)
->count(),
];
->orderByDesc('payment_date')
->first();
// Child businesses using this vendor
$childBusinessesUsing = [];
if ($includeChildren && $business->hasChildBusinesses()) {
$childBusinessesUsing = ApBill::where('vendor_id', $vendor->id)
->whereIn('business_id', $businessIds)
->with('business')
->get()
->pluck('business')
->unique('id')
->values();
}
return [
'vendor' => $vendor,
'total_open_ap' => $totalOpenAp,
'past_due_total' => $pastDueTotal,
'aging' => $aging,
'open_bill_count' => $openBills->count(),
'past_due_count' => $pastDueBills->count(),
'last_payment_date' => $lastPayment?->payment_date,
'last_payment_amount' => $lastPayment?->amount ?? 0,
'highest_aging_bucket' => $this->getHighestAgingBucket($aging),
'avg_payment_days' => $this->calculateAvgPaymentDays($vendor, $businessIds),
'child_businesses_using' => $childBusinessesUsing,
'ytd_paid' => $this->getYtdPaid($vendor, $businessIds),
'total_bills_all_time' => ApBill::where('vendor_id', $vendor->id)
->whereIn('business_id', $businessIds)
->count(),
];
});
}
/**

View File

@@ -0,0 +1,533 @@
<?php
namespace App\Services\Cannaiq;
/**
* Advanced v4 Intelligence Data Transfer Object
*
* Contains advanced brand intelligence analytics including:
* - Brand positioning and differentiation scoring (v3)
* - Trend lead/lag analysis (predictive vs laggy behavior) (v3)
* - Cross-state market signals (v3)
* - Shelf displacement opportunities (v3)
* - Shelf value projections with capture scenarios (v4)
*
* v4.0 Additions:
* - shelfValueProjections: Revenue projections by scope (store/state/multi_state)
* - capture_scenarios: 10%, 25%, 50% market capture modeling
* - opportunity_label: "Big prize, low effort" etc.
* - consumerDemand: Consumer Demand Index + SKU lifecycle stages
* - elasticity: Price elasticity metrics per SKU
* - competitiveThreat: Competitive pressure scoring
* - portfolioBalance: Category mix, redundancy clusters, gaps
*
* All data is derived from existing CannaiQ + internal data; no new scrapes.
*/
class AdvancedV3IntelligenceDTO
{
public function __construct(
// v3.0 fields
public readonly ?array $brandPositioning = null,
public readonly ?array $trendLeadLag = null,
public readonly array $marketSignals = [],
public readonly array $shelfOpportunities = [],
// v4.0: Shelf value projections with capture scenarios
public readonly array $shelfValueProjections = [],
// v4.0: Consumer Demand Index + SKU lifecycle
public readonly ?array $consumerDemand = null,
// v4.0: Price elasticity metrics
public readonly ?array $elasticity = null,
// v4.0: Competitive threat scoring
public readonly ?array $competitiveThreat = null,
// v4.0: Portfolio balance analysis
public readonly ?array $portfolioBalance = null,
) {}
/**
* Create empty DTO when data is unavailable
*/
public static function empty(): self
{
return new self(
brandPositioning: null,
trendLeadLag: null,
marketSignals: [],
shelfOpportunities: [],
shelfValueProjections: [],
consumerDemand: null,
elasticity: null,
competitiveThreat: null,
portfolioBalance: null,
);
}
/**
* Create empty brand positioning structure
*
* Structure:
* - differentiation_score: 0-100 (how unique vs competitors)
* - positioning_label: 'more_of_the_same'|'value_disruptor'|'premium_standout'|'potency_leader'|'format_outlier'
* - comparables: Array of similar brands with distance scores
* - notes: Array of bullet explanations
*/
public static function emptyBrandPositioning(): array
{
return [
'differentiation_score' => null,
'positioning_label' => 'more_of_the_same',
'comparables' => [],
'notes' => [],
];
}
/**
* Create empty trend lead/lag structure
*
* Structure:
* - lead_lag_index: -100 (laggy) to +100 (predictive)
* - classification: 'strong_leader'|'emerging_leader'|'in_line'|'follower'|'laggy'
* - supporting_signals: Array of category-level signals
*/
public static function emptyTrendLeadLag(): array
{
return [
'lead_lag_index' => 0,
'classification' => 'in_line',
'supporting_signals' => [],
];
}
/**
* Create empty market signal structure
*
* Structure:
* - scope: 'multi_state'|'state'|'category'
* - state_code: optional state
* - category: optional category
* - description: human-readable summary
* - trend_strength: 0-100
* - relevant_to_brand: bool
* - brand_fit: 'strong_fit'|'partial_fit'|'gap'
* - example_brand: optional example
*/
public static function emptyMarketSignal(): array
{
return [
'scope' => 'category',
'state_code' => null,
'category' => null,
'description' => '',
'trend_strength' => 0,
'relevant_to_brand' => false,
'brand_fit' => 'gap',
'example_brand' => null,
];
}
/**
* Create empty shelf opportunity structure
*
* Structure:
* - store_id: CannaiQ store external ID
* - store_name: Store display name
* - state_code: State abbreviation
* - opportunity_type: 'whitespace'|'displacement'
* - competitor_brand: null for whitespace
* - competitor_product_name: null for whitespace
* - our_best_sku_id: our matching product ID
* - our_best_sku_name: our matching product name
* - est_monthly_units_current: competitor's current volume
* - est_monthly_units_if_we_win: projected volume if we win
* - est_monthly_revenue_if_we_win: projected revenue
* - quality_score_delta: -100 to +100 (positive = we're better)
* - value_score_delta: -100 to +100 (positive = better value)
* - displacement_difficulty: 'low'|'medium'|'high'
* - difficulty_score: 0-100 (100 = hardest)
* - rationale_tags: Array of reason strings
*/
public static function emptyShelfOpportunity(): array
{
return [
'store_id' => null,
'store_name' => 'Unknown',
'state_code' => null,
'opportunity_type' => 'whitespace',
'competitor_brand' => null,
'competitor_product_name' => null,
'our_best_sku_id' => null,
'our_best_sku_name' => null,
'est_monthly_units_current' => 0,
'est_monthly_units_if_we_win' => 0,
'est_monthly_revenue_if_we_win' => 0,
'quality_score_delta' => 0,
'value_score_delta' => 0,
'displacement_difficulty' => 'medium',
'difficulty_score' => 50,
'rationale_tags' => [],
];
}
/**
* Create empty shelf value projection structure (v4.0)
*
* Structure:
* - scope: 'store'|'state'|'multi_state' - geographic scope of projection
* - store_id: CannaiQ store ID (when scope='store')
* - store_name: Store display name (when scope='store')
* - state_code: State abbreviation (when scope='store' or 'state')
* - current_competitor_sales: Competitor revenue currently on shelf
* - category_total_sales: Total category sales at location
* - our_current_share: Our % of category sales (0.0-1.0)
* - our_current_shelf_value: Our current monthly revenue at location
* - avg_displacement_difficulty: 0-100 (aggregated from opportunities)
* - opportunity_label: 'Big prize, low effort'|'Low-hanging fruit'|'High potential, high difficulty'|'Grind zone'
* - capture_scenarios: Array of capture scenario projections
*/
public static function emptyShelfValueProjection(): array
{
return [
'scope' => 'store',
'store_id' => null,
'store_name' => null,
'state_code' => null,
'current_competitor_sales' => 0,
'category_total_sales' => 0,
'our_current_share' => 0,
'our_current_shelf_value' => 0,
'avg_displacement_difficulty' => 50,
'opportunity_label' => 'Grind zone',
'capture_scenarios' => [],
];
}
/**
* Create empty capture scenario structure (v4.0)
*
* Structure:
* - capture_percent: 10|25|50 - % of competitor shelf to capture
* - projected_monthly_revenue: Revenue if we achieve this capture
* - projected_units: Units if we achieve this capture
* - revenue_lift_from_current: Delta from our current revenue
* - effort_level: 'low'|'medium'|'high' - based on difficulty + capture %
*/
public static function emptyCaptureScenario(): array
{
return [
'capture_percent' => 10,
'projected_monthly_revenue' => 0,
'projected_units' => 0,
'revenue_lift_from_current' => 0,
'effort_level' => 'medium',
];
}
/**
* Get opportunity label based on value and difficulty
*
* @param float $value Estimated monthly revenue opportunity
* @param int $difficulty 0-100 difficulty score
*/
public static function getOpportunityLabel(float $value, int $difficulty): string
{
// High value threshold: $5,000/mo
// Low difficulty threshold: 40
$highValue = $value >= 5000;
$lowDifficulty = $difficulty <= 40;
return match (true) {
$highValue && $lowDifficulty => 'Big prize, low effort',
! $highValue && $lowDifficulty => 'Low-hanging fruit',
$highValue && ! $lowDifficulty => 'High potential, high difficulty',
default => 'Grind zone',
};
}
/**
* Convert to array for views
*/
public function toArray(): array
{
return [
'brandPositioning' => $this->brandPositioning,
'trendLeadLag' => $this->trendLeadLag,
'marketSignals' => $this->marketSignals,
'shelfOpportunities' => $this->shelfOpportunities,
'shelfValueProjections' => $this->shelfValueProjections,
'consumerDemand' => $this->consumerDemand,
'elasticity' => $this->elasticity,
'competitiveThreat' => $this->competitiveThreat,
'portfolioBalance' => $this->portfolioBalance,
];
}
/**
* Check if any v3/v4 intelligence data is available
*/
public function hasData(): bool
{
return $this->brandPositioning !== null
|| $this->trendLeadLag !== null
|| ! empty($this->marketSignals)
|| ! empty($this->shelfOpportunities)
|| ! empty($this->shelfValueProjections)
|| $this->consumerDemand !== null
|| $this->elasticity !== null
|| $this->competitiveThreat !== null
|| $this->portfolioBalance !== null;
}
/**
* Create empty consumer demand structure (v4.0)
*
* Structure:
* - consumer_demand_index: 0-100 overall brand demand score
* - sku_scores: Array of per-SKU demand metrics
*/
public static function emptyConsumerDemand(): array
{
return [
'consumer_demand_index' => null,
'sku_scores' => [],
];
}
/**
* Create empty SKU demand score structure (v4.0)
*
* Structure:
* - product_id: Internal product ID
* - product_name: Display name
* - demand_index: 0-100 demand score
* - promo_independence: 0-100 (higher = sells well without promos)
* - cross_store_consistency: 0-100 (higher = consistent across stores)
* - stage: 'launch'|'growth'|'peak'|'decline'|'terminal'|null
*/
public static function emptySkuDemandScore(): array
{
return [
'product_id' => null,
'product_name' => 'Unknown',
'demand_index' => null,
'promo_independence' => null,
'cross_store_consistency' => null,
'stage' => null,
];
}
/**
* Create empty elasticity structure (v4.0)
*
* Structure:
* - sku_elasticity: Array of per-SKU price elasticity metrics
*/
public static function emptyElasticity(): array
{
return [
'sku_elasticity' => [],
];
}
/**
* Create empty SKU elasticity structure (v4.0)
*
* Structure:
* - product_id: Internal product ID
* - product_name: Display name
* - current_price: Current average price
* - elasticity: Numeric elasticity coefficient (negative = price sensitive)
* - price_behavior: 'sensitive'|'stable'|'room_to_raise'|null
* - note: Human-readable recommendation
*/
public static function emptySkuElasticity(): array
{
return [
'product_id' => null,
'product_name' => 'Unknown',
'current_price' => null,
'elasticity' => null,
'price_behavior' => null,
'note' => null,
];
}
/**
* Create empty competitive threat structure (v4.0)
*
* Structure:
* - overall_threat_score: 0-100 aggregate threat level
* - threat_level: 'low'|'medium'|'high'
* - threats: Array of competitor threat details
*/
public static function emptyCompetitiveThreat(): array
{
return [
'overall_threat_score' => null,
'threat_level' => null,
'threats' => [],
];
}
/**
* Create empty competitor threat structure (v4.0)
*
* Structure:
* - brand_name: Competitor brand name
* - threat_score: 0-100 individual threat score
* - price_aggression: 0-100 (how aggressively they undercut)
* - velocity_trend: -100 to +100 (their growth vs decline)
* - overlap_score: 0-100 (category/store overlap)
* - notes: Array of threat reasons
*/
public static function emptyThreatBrand(): array
{
return [
'brand_name' => 'Unknown',
'threat_score' => null,
'price_aggression' => null,
'velocity_trend' => null,
'overlap_score' => null,
'notes' => [],
];
}
/**
* Create empty portfolio balance structure (v4.0)
*
* Structure:
* - category_mix: Array of category distribution
* - redundancy_clusters: Array of similar SKU groupings
* - gaps: Array of identified portfolio gaps
*/
public static function emptyPortfolioBalance(): array
{
return [
'category_mix' => [],
'redundancy_clusters' => [],
'gaps' => [],
];
}
/**
* Create empty category mix item structure (v4.0)
*/
public static function emptyCategoryMix(): array
{
return [
'category' => 'Unknown',
'sku_count' => 0,
'revenue_share_percent' => null,
];
}
/**
* Create empty redundancy cluster structure (v4.0)
*/
public static function emptyRedundancyCluster(): array
{
return [
'cluster_id' => null,
'label' => 'Unknown',
'product_ids' => [],
'note' => null,
];
}
/**
* Create empty portfolio gap structure (v4.0)
*/
public static function emptyPortfolioGap(): array
{
return [
'category' => 'Unknown',
'description' => null,
];
}
/**
* Get threat level label from score
*/
public static function getThreatLevel(float $score): string
{
return match (true) {
$score >= 70 => 'high',
$score >= 40 => 'medium',
default => 'low',
};
}
/**
* Get lifecycle stage from velocity metrics
*
* @param float $velocity Current daily velocity
* @param float|null $velocityTrend % change vs prior period (-100 to +100)
* @param float $categoryAvgVelocity Category average velocity
*/
public static function getLifecycleStage(float $velocity, ?float $velocityTrend, float $categoryAvgVelocity): string
{
$relativeVelocity = $categoryAvgVelocity > 0 ? $velocity / $categoryAvgVelocity : 0;
// Very low velocity with flat/declining trend = terminal
if ($relativeVelocity < 0.2 && ($velocityTrend === null || $velocityTrend <= 0)) {
return 'terminal';
}
// Low velocity but growing = launch
if ($relativeVelocity < 0.5 && $velocityTrend !== null && $velocityTrend > 20) {
return 'launch';
}
// Medium velocity with strong growth = growth
if ($velocityTrend !== null && $velocityTrend > 10) {
return 'growth';
}
// High velocity, stable = peak
if ($relativeVelocity >= 0.8 && ($velocityTrend === null || abs($velocityTrend) <= 10)) {
return 'peak';
}
// Declining = decline
if ($velocityTrend !== null && $velocityTrend < -10) {
return 'decline';
}
// Default to growth for healthy products
return 'growth';
}
/**
* Get positioning label for display
*/
public function getPositioningLabelDisplay(): string
{
if (! $this->brandPositioning) {
return 'Unknown';
}
return match ($this->brandPositioning['positioning_label'] ?? 'more_of_the_same') {
'value_disruptor' => 'Value Disruptor',
'premium_standout' => 'Premium Standout',
'potency_leader' => 'Potency Leader',
'format_outlier' => 'Format Outlier',
default => 'More of the Same',
};
}
/**
* Get trend classification for display
*/
public function getTrendClassificationDisplay(): string
{
if (! $this->trendLeadLag) {
return 'Unknown';
}
return match ($this->trendLeadLag['classification'] ?? 'in_line') {
'strong_leader' => 'Predictive (Leads Market)',
'emerging_leader' => 'Early Mover',
'follower' => 'Follower',
'laggy' => 'Laggy (Follows Late)',
default => 'In Line with Market',
};
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,337 @@
<?php
namespace App\Services\Cannaiq;
/**
* Brand Analysis Data Transfer Object (v3.0)
*
* Contains all market intelligence data for a brand, structured for the Analysis page.
* When CannaiQ is disabled, contains only internal sales data.
* When CannaiQ is enabled, enriched with market intelligence.
*
* v2.0 Additions:
* - engagement: Buyer outreach and response tracking (always available)
* - sentiment: Store support and brand positioning (CannaiQ only)
*
* v3.0 Additions:
* - advancedV3: Advanced intelligence analytics (CannaiQ only)
* - brandPositioning: Differentiation score and positioning label
* - trendLeadLag: Predictive vs laggy behavior analysis
* - marketSignals: Cross-state market trends
* - shelfOpportunities: Displacement opportunities with difficulty scores
*
* Structure Reference (v1.5):
*
* placement: [
* 'stores' => [...], // List of stores carrying brand
* 'whitespaceStores' => [...], // v1.5: Stores with competitors but not us
* 'whitespaceCount' => int, // v1.5: Count of whitespace opportunities
* 'penetrationByRegion' => [ // v1.5: Regional breakdown
* ['region' => 'CA', 'storeCount' => 10, 'totalStores' => 50, 'penetrationPercent' => 20],
* ],
* ]
*
* competitors: [
* 'competitors' => [...], // List of competitor brands
* 'pricePosition' => 'value'|'mid'|'premium', // v1.5: Our price position
* 'headToHeadSkus' => [ // v1.5: Direct SKU comparisons
* ['ourSku' => '...', 'competitorSku' => '...', 'ourVelocity' => 0.5, ...],
* ],
* 'marketShareTrend' => [ // v1.5: Time series market share
* ['period' => '2025-01', 'ourShare' => 12.5, 'competitor1Share' => 15.2, ...],
* ],
* ]
*
* promoPerformance: [
* [
* 'id' => ..., 'name' => ...,
* 'baselineVelocity' => float, // v1.5: Non-promo velocity
* 'promoVelocity' => float, // v1.5: During-promo velocity
* 'velocityLift' => float, // v1.5: Percent lift
* 'efficiencyScore' => float, // v1.5: Units gained per discount dollar
* ],
* ]
*
* inventoryProjection: [
* 'items' => [ // v1.5: Structured items array
* ['sku' => '...', 'daysOfStock' => int, 'riskLevel' => 'low'|'medium'|'high', ...],
* ],
* 'overstockedItems' => [...], // v1.5: Items with >90 days supply
* 'rollup' => [ // v1.5: Brand-level summary
* 'criticalCount' => int,
* 'warningCount' => int,
* 'overstockedSkuCount' => int,
* 'riskLevel' => 'healthy'|'moderate'|'elevated'|'critical',
* ],
* ]
*
* slippage: [
* 'alerts' => [...], // Basic alerts (existing)
* 'summary' => [ // v1.5: Summary metrics
* 'lostStores30dCount' => int,
* 'lostStores60dCount' => int,
* 'lostSkus30dCount' => int,
* 'competitorTakeoverCount' => int,
* ],
* 'lostStores30d' => [...], // v1.5: List of lost stores
* 'lostStores60d' => [...],
* 'lostSkus30d' => [...], // v1.5: List of lost SKUs
* 'competitorTakeovers' => [...], // v1.5: SKU replacement events
* 'oosMetrics' => [ // v1.5: Out-of-stock metrics
* 'avgOOSDuration' => float,
* 'avgReorderLag' => float,
* 'chronicOOSStores' => [...],
* ],
* ]
*
* engagement: [ // v2.0: Buyer outreach & response (ALWAYS available)
* 'reach' => [
* 'storesContacted30d' => int, // Unique stores contacted
* 'messagesSent30d' => int, // Total outbound messages
* 'touchesPerStore' => float, // Avg touches per store
* 'repActivityLeaders' => [...], // Top reps by activity
* ],
* 'response' => [
* 'responseRate' => float, // 0..1 reply rate
* 'avgResponseTimeHours' => float|null, // Median reply time
* 'storesNotResponding' => int, // Silent accounts
* 'mostEngagedStores' => [...], // Top responding stores
* ],
* 'actions' => [
* 'quotesIssued30d' => int, // Quotes tied to brand
* 'ordersPlaced30d' => int, // Orders with brand products
* 'conversionRate' => float|null, // Quotes → Orders
* 'reorderRate' => float|null, // Repeat buyers
* 'atRiskAccounts' => [...], // Accounts needing attention
* ],
* 'quality' => [
* 'touchTypeBreakdown' => [...], // By channel type
* 'buyerEngagementScore' => float|null, // 0..100
* 'buyerEngagementLabel' => string, // "Strong partner" / "Healthy" / "At risk" / "Needs action"
* ],
* ]
*
* sentiment: [ // v2.0: Store support (CannaiQ ONLY - null when disabled)
* 'storeSupport' => [
* 'storesPromotingBrand30d' => int, // Stores with active promos
* 'promoFrequencyPerStore' => float|null,// Promos per store
* 'featuredPlacementCount' => int, // Featured/specials count
* 'avgShelfShare' => float|null, // Category share
* 'storeSentimentScore' => float|null, // 0..100
* 'storeSentimentLabel' => string, // "Advocates" / "Supportive" / "Neutral" / "Unsupportive"
* ],
* 'pricingBehavior' => [
* 'avgDiscountRate' => float|null, // Avg promo discount
* 'priceRespectIndex' => float|null, // 0..100 (MSRP adherence)
* 'competitorPricePressure' => float|null, // 0..100
* ],
* 'inventoryBehavior' => [
* 'sellThroughAfterRestock' => float|null, // Units/day post-restock
* 'restockUrgencyIndex' => float|null, // 0..100 (faster reorders = higher)
* 'stockNeglectEvents' => int, // Extended OOS events
* 'shelfCommitment' => [
* 'singleSkuStores' => int, // Stores with 1 SKU
* 'multiSkuStores' => int, // Stores with 3+ SKUs
* 'avgSkusPerStore' => float|null, // Avg SKU depth
* ],
* ],
* ]
*/
class BrandAnalysisDTO
{
public function __construct(
// Core metadata
public readonly int $brandId,
public readonly string $brandName,
public readonly bool $cannaiqEnabled,
public readonly ?\DateTimeInterface $dataFreshness = null,
// Connection error message (when CannaiQ is enabled but API fails)
public readonly ?string $connectionError = null,
// Store placement data (v1.5: enriched with whitespace + regional)
public readonly array $placement = [],
// Competitor analysis (v1.5: enriched with head-to-head + trends)
public readonly array $competitors = [],
// SKU performance data
public readonly array $skuPerformance = [],
// Promo performance data (v1.5: enriched with lift + efficiency)
public readonly array $promoPerformance = [],
// Inventory projections (v1.5: enriched with risk levels + rollup)
public readonly array $inventoryProjection = [],
// Slippage/velocity warnings (v1.5: fully structured)
public readonly array $slippage = [],
// Summary metrics (v1.5: enriched with whitespace count)
public readonly array $summary = [],
// v2.0: Buyer engagement (internal CRM + orders - ALWAYS available)
public readonly array $engagement = [],
// v2.0: Store sentiment (CannaiQ data - ONLY when cannaiq_enabled)
public readonly ?array $sentiment = null,
// v3.0: Advanced intelligence (CannaiQ data - ONLY when cannaiq_enabled)
public readonly ?AdvancedV3IntelligenceDTO $advancedV3 = null,
) {}
/**
* Create empty DTO for when data is unavailable
*/
public static function empty(int $brandId, string $brandName, bool $cannaiqEnabled): self
{
return new self(
brandId: $brandId,
brandName: $brandName,
cannaiqEnabled: $cannaiqEnabled,
dataFreshness: null,
placement: [
'stores' => [],
'whitespaceStores' => [],
'whitespaceCount' => 0,
'penetrationByRegion' => [],
],
competitors: [
'competitors' => [],
'pricePosition' => null,
'headToHeadSkus' => [],
'marketShareTrend' => [],
],
skuPerformance: [],
promoPerformance: [],
inventoryProjection: [
'items' => [],
'overstockedItems' => [],
'rollup' => [
'criticalCount' => 0,
'warningCount' => 0,
'overstockedSkuCount' => 0,
'riskLevel' => 'healthy',
],
],
slippage: [
'alerts' => [],
'summary' => [
'lostStores30dCount' => 0,
'lostStores60dCount' => 0,
'lostSkus30dCount' => 0,
'competitorTakeoverCount' => 0,
],
'lostStores30d' => [],
'lostStores60d' => [],
'lostSkus30d' => [],
'competitorTakeovers' => [],
'oosMetrics' => [
'avgOOSDuration' => null,
'avgReorderLag' => null,
'chronicOOSStores' => [],
],
],
summary: [
'totalStores' => 0,
'totalSkus' => 0,
'avgPrice' => 0,
'marketShare' => null,
'pricePosition' => null,
'whitespaceCount' => 0,
],
engagement: self::emptyEngagement(),
sentiment: null,
advancedV3: null,
);
}
/**
* Get empty engagement structure
*/
public static function emptyEngagement(): array
{
return [
'reach' => [
'storesContacted30d' => 0,
'messagesSent30d' => 0,
'touchesPerStore' => 0,
'repActivityLeaders' => [],
],
'response' => [
'responseRate' => 0,
'avgResponseTimeHours' => null,
'storesNotResponding' => 0,
'mostEngagedStores' => [],
],
'actions' => [
'quotesIssued30d' => 0,
'ordersPlaced30d' => 0,
'conversionRate' => null,
'reorderRate' => null,
'atRiskAccounts' => [],
],
'quality' => [
'touchTypeBreakdown' => [],
'buyerEngagementScore' => null,
'buyerEngagementLabel' => 'Needs action',
],
];
}
/**
* Get empty sentiment structure
*/
public static function emptySentiment(): array
{
return [
'storeSupport' => [
'storesPromotingBrand30d' => 0,
'promoFrequencyPerStore' => null,
'featuredPlacementCount' => 0,
'avgShelfShare' => null,
'storeSentimentScore' => null,
'storeSentimentLabel' => 'Neutral',
],
'pricingBehavior' => [
'avgDiscountRate' => null,
'priceRespectIndex' => null,
'competitorPricePressure' => null,
],
'inventoryBehavior' => [
'sellThroughAfterRestock' => null,
'restockUrgencyIndex' => null,
'stockNeglectEvents' => 0,
'shelfCommitment' => [
'singleSkuStores' => 0,
'multiSkuStores' => 0,
'avgSkusPerStore' => null,
],
],
];
}
/**
* Convert to array for views
*/
public function toArray(): array
{
return [
'brandId' => $this->brandId,
'brandName' => $this->brandName,
'cannaiqEnabled' => $this->cannaiqEnabled,
'connectionError' => $this->connectionError,
'dataFreshness' => $this->dataFreshness?->format('Y-m-d H:i:s'),
'placement' => $this->placement,
'competitors' => $this->competitors,
'skuPerformance' => $this->skuPerformance,
'promoPerformance' => $this->promoPerformance,
'inventoryProjection' => $this->inventoryProjection,
'slippage' => $this->slippage,
'summary' => $this->summary,
'engagement' => $this->engagement,
'sentiment' => $this->sentiment,
'advancedV3' => $this->advancedV3?->toArray(),
];
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,571 @@
<?php
namespace App\Services\Cannaiq;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* CannaiQ API Client
*
* Connects to the CannaiQ Marketing Intelligence API to fetch:
* - Store metrics (pricing position, market share, trends)
* - Product metrics (velocity, pricing history, competitor positioning)
* - Competitor snapshots (out-of-stock, pricing, promotions)
*
* API Base URL: https://cannaiq.co/api/v1
* Authentication: X-API-Key header (trusted origins and localhost bypass auth)
*/
class CannaiqClient
{
protected PendingRequest $http;
protected string $baseUrl;
protected ?string $apiKey;
public function __construct()
{
$this->baseUrl = config('services.cannaiq.base_url', 'https://cannaiq.co/api/v1');
$this->apiKey = config('services.cannaiq.api_key');
$this->http = Http::baseUrl($this->baseUrl)
->timeout(30)
->retry(3, 100, function ($exception) {
return $exception instanceof \Illuminate\Http\Client\ConnectionException;
})
->withHeaders($this->getHeaders());
}
/**
* Get headers for API requests
*/
protected function getHeaders(): array
{
$headers = [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
];
// Add API key if configured (not needed for trusted origins *.cannabrands.app)
if ($this->apiKey) {
$headers['X-API-Key'] = $this->apiKey;
}
return $headers;
}
/**
* List all stores (paginated)
*
* @param int $limit Number of stores to return
* @param int $offset Pagination offset
*/
public function listStores(int $limit = 50, int $offset = 0): array
{
try {
$response = $this->http->get('/stores', [
'limit' => $limit,
'offset' => $offset,
]);
if ($response->successful()) {
return $response->json();
}
Log::warning('CannaiQ: Failed to list stores', [
'status' => $response->status(),
]);
return ['error' => true, 'message' => 'Failed to list stores', 'stores' => []];
} catch (\Exception $e) {
Log::error('CannaiQ: Exception listing stores', [
'error' => $e->getMessage(),
]);
return ['error' => true, 'message' => $e->getMessage(), 'stores' => []];
}
}
/**
* Get store details
*
* @param string $storeId CannaiQ store ID
*/
public function getStore(string $storeId): array
{
try {
$response = $this->http->get("/stores/{$storeId}");
if ($response->successful()) {
return $response->json();
}
Log::warning('CannaiQ: Failed to fetch store', [
'store_id' => $storeId,
'status' => $response->status(),
]);
return ['error' => true, 'message' => 'Failed to fetch store'];
} catch (\Exception $e) {
Log::error('CannaiQ: Exception fetching store', [
'store_id' => $storeId,
'error' => $e->getMessage(),
]);
return ['error' => true, 'message' => $e->getMessage()];
}
}
/**
* Get store performance metrics
* Returns: product counts, brands, categories, price stats, stock health
*
* @param string $storeId CannaiQ store ID
*/
public function getStoreMetrics(string $storeId): array
{
try {
$response = $this->http->get("/stores/{$storeId}/metrics");
if ($response->successful()) {
return $response->json();
}
Log::warning('CannaiQ: Failed to fetch store metrics', [
'store_id' => $storeId,
'status' => $response->status(),
'body' => $response->body(),
]);
return ['error' => true, 'message' => 'Failed to fetch store metrics'];
} catch (\Exception $e) {
Log::error('CannaiQ: Exception fetching store metrics', [
'store_id' => $storeId,
'error' => $e->getMessage(),
]);
return ['error' => true, 'message' => $e->getMessage()];
}
}
/**
* Get store's product catalog
*
* @param string $storeId CannaiQ store ID
* @param int $limit Number of products to return
* @param int $offset Pagination offset
*/
public function getStoreProducts(string $storeId, int $limit = 100, int $offset = 0): array
{
try {
$response = $this->http->get("/stores/{$storeId}/products", [
'limit' => $limit,
'offset' => $offset,
]);
if ($response->successful()) {
return $response->json();
}
Log::warning('CannaiQ: Failed to fetch store products', [
'store_id' => $storeId,
'status' => $response->status(),
]);
return ['error' => true, 'message' => 'Failed to fetch store products', 'products' => []];
} catch (\Exception $e) {
Log::error('CannaiQ: Exception fetching store products', [
'store_id' => $storeId,
'error' => $e->getMessage(),
]);
return ['error' => true, 'message' => $e->getMessage(), 'products' => []];
}
}
/**
* Get product-level metrics with price changes since last crawl
*
* @param string $storeId CannaiQ store ID
* @param int $limit Number of products to return
* @param int $offset Pagination offset
*/
public function getStoreProductMetrics(string $storeId, int $limit = 100, int $offset = 0): array
{
try {
$response = $this->http->get("/stores/{$storeId}/product-metrics", [
'limit' => $limit,
'offset' => $offset,
]);
if ($response->successful()) {
return $response->json();
}
Log::warning('CannaiQ: Failed to fetch product metrics', [
'store_id' => $storeId,
'status' => $response->status(),
]);
return ['error' => true, 'message' => 'Failed to fetch product metrics', 'products' => []];
} catch (\Exception $e) {
Log::error('CannaiQ: Exception fetching product metrics', [
'store_id' => $storeId,
'error' => $e->getMessage(),
]);
return ['error' => true, 'message' => $e->getMessage(), 'products' => []];
}
}
/**
* Get competitor snapshot for a store
* Returns: nearby competitors, price comparisons, brand overlap
*
* @param string $storeId CannaiQ store ID
*/
public function getStoreCompetitorSnapshot(string $storeId): array
{
try {
$response = $this->http->get("/stores/{$storeId}/competitor-snapshot");
if ($response->successful()) {
return $response->json();
}
Log::warning('CannaiQ: Failed to fetch competitor snapshot', [
'store_id' => $storeId,
'status' => $response->status(),
]);
return ['error' => true, 'message' => 'Failed to fetch competitor snapshot'];
} catch (\Exception $e) {
Log::error('CannaiQ: Exception fetching competitor snapshot', [
'store_id' => $storeId,
'error' => $e->getMessage(),
]);
return ['error' => true, 'message' => $e->getMessage()];
}
}
/**
* Search products across all stores
*
* @param string $query Search query
* @param array $filters Optional filters (category, brand, etc)
*/
public function searchProducts(string $query, array $filters = []): array
{
try {
$params = array_merge(['q' => $query, 'limit' => 50], $filters);
$response = $this->http->get('/products', $params);
if ($response->successful()) {
return $response->json();
}
return ['error' => true, 'message' => 'Search failed', 'products' => []];
} catch (\Exception $e) {
Log::error('CannaiQ: Exception searching products', [
'query' => $query,
'error' => $e->getMessage(),
]);
return ['error' => true, 'message' => $e->getMessage(), 'products' => []];
}
}
/**
* Get product details
*
* @param int|string $productId CannaiQ product ID
*/
public function getProduct(int|string $productId): array
{
try {
$response = $this->http->get("/products/{$productId}");
if ($response->successful()) {
return $response->json();
}
return ['error' => true, 'message' => 'Failed to fetch product'];
} catch (\Exception $e) {
Log::error('CannaiQ: Exception fetching product', [
'product_id' => $productId,
'error' => $e->getMessage(),
]);
return ['error' => true, 'message' => $e->getMessage()];
}
}
/**
* Get product price/stock history
*
* @param int|string $productId CannaiQ product ID
*/
public function getProductHistory(int|string $productId): array
{
try {
$response = $this->http->get("/products/{$productId}/history");
if ($response->successful()) {
return $response->json();
}
return ['error' => true, 'message' => 'Failed to fetch product history', 'history' => []];
} catch (\Exception $e) {
Log::error('CannaiQ: Exception fetching product history', [
'product_id' => $productId,
'error' => $e->getMessage(),
]);
return ['error' => true, 'message' => $e->getMessage(), 'history' => []];
}
}
/**
* List all brands
*/
public function listBrands(): array
{
try {
$response = $this->http->get('/brands');
if ($response->successful()) {
return $response->json();
}
return ['error' => true, 'message' => 'Failed to list brands', 'brands' => []];
} catch (\Exception $e) {
Log::error('CannaiQ: Exception listing brands', [
'error' => $e->getMessage(),
]);
return ['error' => true, 'message' => $e->getMessage(), 'brands' => []];
}
}
/**
* Get brand details and products
*
* @param string $brandName Brand name/slug
*/
public function getBrand(string $brandName): array
{
try {
$response = $this->http->get("/brands/{$brandName}");
if ($response->successful()) {
return $response->json();
}
return ['error' => true, 'message' => 'Failed to fetch brand'];
} catch (\Exception $e) {
Log::error('CannaiQ: Exception fetching brand', [
'brand' => $brandName,
'error' => $e->getMessage(),
]);
return ['error' => true, 'message' => $e->getMessage()];
}
}
/**
* List all categories
*/
public function listCategories(): array
{
try {
$response = $this->http->get('/categories');
if ($response->successful()) {
return $response->json();
}
return ['error' => true, 'message' => 'Failed to list categories', 'categories' => []];
} catch (\Exception $e) {
Log::error('CannaiQ: Exception listing categories', [
'error' => $e->getMessage(),
]);
return ['error' => true, 'message' => $e->getMessage(), 'categories' => []];
}
}
/**
* Check API health (full system health)
*/
public function healthCheck(): array
{
try {
$response = $this->http->get('/health/full');
return [
'healthy' => $response->successful(),
'status' => $response->status(),
'data' => $response->json(),
];
} catch (\Exception $e) {
return [
'healthy' => false,
'error' => $e->getMessage(),
];
}
}
/**
* Check basic API health
*/
public function ping(): array
{
try {
$response = $this->http->get('/health');
return [
'healthy' => $response->successful(),
'data' => $response->json(),
];
} catch (\Exception $e) {
return [
'healthy' => false,
'error' => $e->getMessage(),
];
}
}
// ========================================
// Brand Analytics API Endpoints (v1.5)
// These endpoints provide brand-level intelligence
// ========================================
/**
* Get brand-level metrics including whitespace and regional penetration
*
* @param string $brandName Brand name/slug
*/
public function getBrandMetrics(string $brandName): array
{
try {
$response = $this->http->get("/brands/{$brandName}/metrics");
if ($response->successful()) {
return $response->json();
}
Log::warning('CannaiQ: Failed to fetch brand metrics', [
'brand' => $brandName,
'status' => $response->status(),
]);
return ['error' => true, 'message' => 'Failed to fetch brand metrics'];
} catch (\Exception $e) {
Log::error('CannaiQ: Exception fetching brand metrics', [
'brand' => $brandName,
'error' => $e->getMessage(),
]);
return ['error' => true, 'message' => $e->getMessage()];
}
}
/**
* Get competitor analysis for a brand
* Returns: head-to-head comparisons, market share trends, price position
*
* @param string $brandName Brand name/slug
* @param array $options Optional parameters (top_n, etc)
*/
public function getBrandCompetitors(string $brandName, array $options = []): array
{
try {
$response = $this->http->get("/brands/{$brandName}/competitors", $options);
if ($response->successful()) {
return $response->json();
}
Log::warning('CannaiQ: Failed to fetch brand competitors', [
'brand' => $brandName,
'status' => $response->status(),
]);
return ['error' => true, 'message' => 'Failed to fetch brand competitors'];
} catch (\Exception $e) {
Log::error('CannaiQ: Exception fetching brand competitors', [
'brand' => $brandName,
'error' => $e->getMessage(),
]);
return ['error' => true, 'message' => $e->getMessage()];
}
}
/**
* Get promotion performance metrics for a brand
* Returns: velocity lift, baseline vs promo velocity, efficiency scores
*
* @param string $brandName Brand name/slug
* @param array $options Optional parameters (from, to date range)
*/
public function getBrandPromoMetrics(string $brandName, array $options = []): array
{
try {
$response = $this->http->get("/brands/{$brandName}/promo-metrics", $options);
if ($response->successful()) {
return $response->json();
}
Log::warning('CannaiQ: Failed to fetch brand promo metrics', [
'brand' => $brandName,
'status' => $response->status(),
]);
return ['error' => true, 'message' => 'Failed to fetch brand promo metrics'];
} catch (\Exception $e) {
Log::error('CannaiQ: Exception fetching brand promo metrics', [
'brand' => $brandName,
'error' => $e->getMessage(),
]);
return ['error' => true, 'message' => $e->getMessage()];
}
}
/**
* Get slippage/churn metrics for a brand
* Returns: lost stores, lost SKUs, competitor takeovers, OOS metrics
*
* @param string $brandName Brand name/slug
* @param array $options Optional parameters (days_back, etc)
*/
public function getBrandSlippage(string $brandName, array $options = []): array
{
try {
$response = $this->http->get("/brands/{$brandName}/slippage", $options);
if ($response->successful()) {
return $response->json();
}
Log::warning('CannaiQ: Failed to fetch brand slippage', [
'brand' => $brandName,
'status' => $response->status(),
]);
return ['error' => true, 'message' => 'Failed to fetch brand slippage'];
} catch (\Exception $e) {
Log::error('CannaiQ: Exception fetching brand slippage', [
'brand' => $brandName,
'error' => $e->getMessage(),
]);
return ['error' => true, 'message' => $e->getMessage()];
}
}
}

View File

@@ -10,6 +10,7 @@ use App\Models\Crm\CrmMessageAttachment;
use App\Models\Crm\CrmThread;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
/**
* CRM Channel Service - Omnichannel messaging
@@ -131,6 +132,215 @@ class CrmChannelService
return $message;
}
/**
* Receive an inbound SMS message.
*
* This method resolves business/channel by the receiving phone number,
* finds or creates a thread, and stores the message in the CRM system.
*
* Provider-agnostic: works with any SMS gateway that normalizes to this format.
*
* @param array $payload Expected keys:
* - provider: SMS gateway identifier (e.g., 'gateway', 'twilio')
* - to_number: our phone number (identifies business/channel)
* - from_number: sender phone number
* - body: message text
* - external_message_id: unique message ID from gateway (optional)
* - meta: raw webhook payload (optional)
* - attachments: array of attachment metadata (optional)
*/
public function receiveInboundSms(array $payload): ?CrmChannelMessage
{
$provider = $payload['provider'] ?? 'gateway';
$toNumber = $payload['to_number'] ?? null;
$fromNumber = $payload['from_number'] ?? null;
$body = $payload['body'] ?? '';
$externalMessageId = $payload['external_message_id'] ?? null;
if (! $toNumber || ! $fromNumber) {
Log::warning('receiveInboundSms: Missing to_number or from_number');
return null;
}
// 1) Find CRM channel by phone number
$channel = $this->findChannelByPhoneNumber($toNumber);
if (! $channel) {
Log::info("receiveInboundSms: No CRM channel found for {$toNumber}");
return null;
}
$businessId = $channel->business_id;
// 2) Find contact by phone number
$contact = $this->findContactByAddress($businessId, $fromNumber, CrmChannel::TYPE_SMS);
// 3) Find or create thread
$thread = $this->findOrCreateSmsThread($businessId, $channel, $contact, $fromNumber);
// 4) Check for duplicate using external_message_id
if ($externalMessageId) {
$existing = CrmChannelMessage::where('external_id', $externalMessageId)->first();
if ($existing) {
Log::info("receiveInboundSms: Duplicate message {$externalMessageId}");
return $existing;
}
}
// 5) Create message
$message = CrmChannelMessage::create([
'business_id' => $businessId,
'thread_id' => $thread->id,
'channel_id' => $channel->id,
'contact_id' => $contact?->id,
'channel_type' => CrmChannel::TYPE_SMS,
'direction' => CrmChannelMessage::DIRECTION_INBOUND,
'external_id' => $externalMessageId,
'from_address' => $fromNumber,
'to_address' => $toNumber,
'body' => $body,
'status' => CrmChannelMessage::STATUS_DELIVERED,
'delivered_at' => now(),
'metadata' => [
'provider' => $provider,
'attachments' => $payload['attachments'] ?? [],
'raw' => $payload['meta'] ?? [],
],
]);
// 6) Update thread
$thread->update([
'last_message_at' => now(),
'last_message_direction' => CrmChannelMessage::DIRECTION_INBOUND,
'last_message_preview' => Str::limit($body, 100),
'last_channel_type' => CrmChannel::TYPE_SMS,
'is_read' => false,
'read_at' => null,
'read_by' => null,
]);
// Reopen if closed
if ($thread->status === CrmThread::STATUS_CLOSED) {
$thread->update(['status' => CrmThread::STATUS_OPEN]);
}
// 7) Trigger automations
app(CrmAutomationService::class)->trigger('message_received', [
'business_id' => $businessId,
'message' => $message,
'thread' => $thread,
'contact' => $contact,
'channel_type' => CrmChannel::TYPE_SMS,
]);
return $message;
}
/**
* Find CRM channel by phone number.
*
* Checks the channel identifier and config for matching phone numbers.
*/
protected function findChannelByPhoneNumber(string $phoneNumber): ?CrmChannel
{
// Normalize phone number
$normalized = preg_replace('/[^0-9]/', '', $phoneNumber);
$withPlus = '+'.ltrim($phoneNumber, '+');
// First check identifier directly
$channel = CrmChannel::where('type', CrmChannel::TYPE_SMS)
->where('is_active', true)
->where(function ($q) use ($phoneNumber, $normalized, $withPlus) {
$q->where('identifier', $phoneNumber)
->orWhere('identifier', $normalized)
->orWhere('identifier', $withPlus);
})
->first();
if ($channel) {
return $channel;
}
// Check config JSON for phone_number
// This is a fallback for channels that store phone in config
$channels = CrmChannel::where('type', CrmChannel::TYPE_SMS)
->where('is_active', true)
->get();
foreach ($channels as $ch) {
$config = $ch->config ?? [];
$configPhone = $config['phone_number'] ?? $config['from_number'] ?? null;
if ($configPhone) {
$configNormalized = preg_replace('/[^0-9]/', '', $configPhone);
if ($configNormalized === $normalized) {
return $ch;
}
}
}
return null;
}
/**
* Find or create a thread for SMS conversation.
*/
protected function findOrCreateSmsThread(
int $businessId,
CrmChannel $channel,
?Contact $contact,
string $fromNumber
): CrmThread {
// Try to find existing open thread with same contact or phone
if ($contact) {
$thread = CrmThread::forBusiness($businessId)
->where('contact_id', $contact->id)
->whereIn('status', [CrmThread::STATUS_OPEN, CrmThread::STATUS_SNOOZED])
->where('last_channel_type', CrmChannel::TYPE_SMS)
->orderBy('last_message_at', 'desc')
->first();
if ($thread) {
return $thread;
}
}
// Try to find by stored phone number in metadata
$thread = CrmThread::forBusiness($businessId)
->whereIn('status', [CrmThread::STATUS_OPEN, CrmThread::STATUS_SNOOZED])
->where('last_channel_type', CrmChannel::TYPE_SMS)
->whereHas('messages', function ($q) use ($fromNumber) {
$q->where('from_address', $fromNumber)
->where('direction', CrmChannelMessage::DIRECTION_INBOUND);
})
->orderBy('last_message_at', 'desc')
->first();
if ($thread) {
// Update contact_id if we now have a contact
if ($contact && ! $thread->contact_id) {
$thread->update(['contact_id' => $contact->id]);
}
return $thread;
}
// Create new thread
return CrmThread::create([
'business_id' => $businessId,
'contact_id' => $contact?->id,
'account_id' => $contact?->buyer_business_id,
'status' => CrmThread::STATUS_OPEN,
'priority' => CrmThread::PRIORITY_NORMAL,
'last_channel_type' => CrmChannel::TYPE_SMS,
'last_message_at' => now(),
'last_message_direction' => CrmChannelMessage::DIRECTION_INBOUND,
]);
}
/**
* Actually send a message through the provider
*/

View File

@@ -0,0 +1,341 @@
<?php
namespace App\Services\Email;
use App\Models\Brand;
use App\Models\Business;
use App\Models\BusinessEmailIdentity;
use App\Models\Contact;
use App\Models\Crm\CrmChannel;
use App\Models\Crm\CrmChannelMessage;
use App\Models\Crm\CrmThread;
use App\Services\Crm\CrmAutomationService;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
/**
* InboundEmailService - Handles incoming emails for CRM
*
* This service receives normalized email payloads from provider-specific
* webhook controllers and routes them to the appropriate CRM threads.
*
* It does NOT handle SMTP credentials or sending - that's EmailSender's job.
*/
class InboundEmailService
{
/**
* Handle an inbound email.
*
* Expected payload keys:
* - provider: 'sendgrid' | 'postmark' | 'ses'
* - from_email: sender email address
* - from_name: sender display name (optional)
* - to_email: recipient email address (our identity)
* - subject: email subject
* - text_body: plain text body
* - html_body: HTML body (optional)
* - message_id: email Message-ID header
* - in_reply_to: In-Reply-To header (optional)
* - references: References header (optional)
* - headers: all headers as associative array (optional)
* - attachments: array of attachment metadata (optional)
*/
public function handleInbound(array $payload): ?CrmChannelMessage
{
$toEmail = strtolower(trim($payload['to_email'] ?? ''));
if (! $toEmail) {
Log::warning('InboundEmailService: No to_email in payload');
return null;
}
// 1) Resolve business + email identity
$identity = BusinessEmailIdentity::findByEmail($toEmail);
if (! $identity) {
Log::info("InboundEmailService: No identity found for {$toEmail}");
return null;
}
$business = $identity->business;
if (! $business) {
Log::warning("InboundEmailService: Identity {$identity->id} has no business");
return null;
}
// 2) Get or create CRM channel
$channel = $identity->getOrCreateChannel();
// 3) Find contact by from_email
$fromEmail = strtolower(trim($payload['from_email'] ?? ''));
$contact = $this->findOrCreateContact($business, $fromEmail, $payload['from_name'] ?? null);
// 4) Find or create thread (using In-Reply-To / Message-ID / contact)
$thread = $this->findOrCreateThread($business, $channel, $contact, $payload);
// 5) Store the inbound message
$message = $this->storeInboundMessage($thread, $channel, $contact, $payload);
// 6) Update identity last received timestamp
$identity->recordReceived();
// 7) Trigger automations
app(CrmAutomationService::class)->trigger('message_received', [
'business_id' => $business->id,
'message' => $message,
'thread' => $thread,
'contact' => $contact,
'channel_type' => CrmChannel::TYPE_EMAIL,
]);
Log::info("InboundEmailService: Created message {$message->id} in thread {$thread->id} for {$toEmail}");
return $message;
}
/**
* Find or create a contact from the sender's email.
*/
protected function findOrCreateContact(Business $business, string $email, ?string $name): ?Contact
{
if (! $email) {
return null;
}
// First try to find existing contact
$contact = Contact::where('business_id', $business->id)
->where('email', $email)
->first();
if ($contact) {
return $contact;
}
// Parse name into first/last
$firstName = null;
$lastName = null;
if ($name) {
$parts = explode(' ', trim($name), 2);
$firstName = $parts[0] ?? null;
$lastName = $parts[1] ?? null;
}
// Create new contact
return Contact::create([
'business_id' => $business->id,
'email' => $email,
'first_name' => $firstName,
'last_name' => $lastName,
'source' => 'email_inbound',
]);
}
/**
* Find existing thread or create a new one.
*
* Threading logic:
* 1. Check In-Reply-To header match to existing message's external_id
* 2. Check References header match to any message's external_id
* 3. Check Message-ID ensure we haven't already processed this email
* 4. Find open thread for same contact on same channel
* 5. Create new thread if no match
*/
protected function findOrCreateThread(
Business $business,
CrmChannel $channel,
?Contact $contact,
array $payload
): CrmThread {
$messageId = $payload['message_id'] ?? null;
$inReplyTo = $payload['in_reply_to'] ?? null;
$references = $payload['references'] ?? null;
// 1) Check In-Reply-To header
if ($inReplyTo) {
$thread = $this->findThreadByMessageId($business->id, $inReplyTo);
if ($thread) {
$this->ensureThreadHasChannelInfo($thread, $channel);
return $thread;
}
}
// 2) Check References header (can be multiple message IDs)
if ($references) {
$refIds = is_array($references) ? $references : preg_split('/\s+/', $references);
foreach ($refIds as $refId) {
$thread = $this->findThreadByMessageId($business->id, trim($refId));
if ($thread) {
$this->ensureThreadHasChannelInfo($thread, $channel);
return $thread;
}
}
}
// 3) Check if we already have this exact message (dedup)
if ($messageId) {
$existingMessage = CrmChannelMessage::where('business_id', $business->id)
->where('external_id', $messageId)
->first();
if ($existingMessage && $existingMessage->thread) {
$this->ensureThreadHasChannelInfo($existingMessage->thread, $channel);
return $existingMessage->thread;
}
}
// 4) Find open thread for same contact on email channel
if ($contact) {
$thread = CrmThread::forBusiness($business->id)
->where('contact_id', $contact->id)
->where('last_channel_type', CrmChannel::TYPE_EMAIL)
->whereIn('status', [CrmThread::STATUS_OPEN, CrmThread::STATUS_SNOOZED])
->orderBy('last_message_at', 'desc')
->first();
if ($thread) {
$this->ensureThreadHasChannelInfo($thread, $channel);
return $thread;
}
}
// 5) Find brand associated with this channel (if any)
$brandId = $this->findBrandForChannel($business->id, $channel->id);
// 6) Create new thread
return CrmThread::create([
'business_id' => $business->id,
'brand_id' => $brandId,
'channel_id' => $channel->id,
'department' => $channel->department,
'contact_id' => $contact?->id,
'account_id' => $contact?->buyer_business_id,
'subject' => $payload['subject'] ?? null,
'status' => CrmThread::STATUS_OPEN,
'priority' => CrmThread::PRIORITY_NORMAL,
'last_channel_type' => CrmChannel::TYPE_EMAIL,
'last_message_at' => now(),
'last_message_direction' => CrmChannelMessage::DIRECTION_INBOUND,
]);
}
/**
* Find the brand associated with a channel (via inbound_email_channel_id).
*/
protected function findBrandForChannel(int $businessId, int $channelId): ?int
{
$brand = Brand::where('business_id', $businessId)
->where('inbound_email_channel_id', $channelId)
->first();
return $brand?->id;
}
/**
* Ensure an existing thread has channel info set (backfill for older threads).
*/
protected function ensureThreadHasChannelInfo(CrmThread $thread, CrmChannel $channel): void
{
$updates = [];
// Set channel_id if missing
if (! $thread->channel_id) {
$updates['channel_id'] = $channel->id;
}
// Set department if missing
if (! $thread->department && $channel->department) {
$updates['department'] = $channel->department;
}
// Set brand_id if missing and channel has associated brand
if (! $thread->brand_id) {
$brandId = $this->findBrandForChannel($thread->business_id, $channel->id);
if ($brandId) {
$updates['brand_id'] = $brandId;
}
}
if (! empty($updates)) {
$thread->update($updates);
}
}
/**
* Find thread by a message's external_id (Message-ID header).
*/
protected function findThreadByMessageId(int $businessId, string $messageId): ?CrmThread
{
$message = CrmChannelMessage::where('business_id', $businessId)
->where(function ($q) use ($messageId) {
$q->where('external_id', $messageId)
->orWhereJsonContains('metadata->message_id', $messageId);
})
->first();
return $message?->thread;
}
/**
* Store the inbound email as a CRM message.
*/
protected function storeInboundMessage(
CrmThread $thread,
CrmChannel $channel,
?Contact $contact,
array $payload
): CrmChannelMessage {
$message = CrmChannelMessage::create([
'business_id' => $thread->business_id,
'thread_id' => $thread->id,
'channel_id' => $channel->id,
'contact_id' => $contact?->id,
'channel_type' => CrmChannel::TYPE_EMAIL,
'direction' => CrmChannelMessage::DIRECTION_INBOUND,
'external_id' => $payload['message_id'] ?? null,
'from_address' => $payload['from_email'] ?? null,
'to_address' => $payload['to_email'] ?? null,
'subject' => $payload['subject'] ?? null,
'body' => $payload['text_body'] ?? strip_tags($payload['html_body'] ?? ''),
'body_html' => $payload['html_body'] ?? null,
'body_plain' => $payload['text_body'] ?? null,
'status' => CrmChannelMessage::STATUS_DELIVERED,
'delivered_at' => now(),
'metadata' => [
'provider' => $payload['provider'] ?? null,
'message_id' => $payload['message_id'] ?? null,
'in_reply_to' => $payload['in_reply_to'] ?? null,
'references' => $payload['references'] ?? null,
'headers' => $payload['headers'] ?? [],
'attachments' => $payload['attachments'] ?? [],
'from_name' => $payload['from_name'] ?? null,
],
]);
// Update thread
$thread->update([
'last_message_at' => now(),
'last_message_direction' => CrmChannelMessage::DIRECTION_INBOUND,
'last_message_preview' => Str::limit($message->body, 100),
'last_channel_type' => CrmChannel::TYPE_EMAIL,
'is_read' => false,
'read_at' => null,
'read_by' => null,
]);
// Reopen thread if it was closed
if ($thread->status === CrmThread::STATUS_CLOSED) {
$thread->update(['status' => CrmThread::STATUS_OPEN]);
}
return $message;
}
}

View File

@@ -0,0 +1,705 @@
<?php
namespace App\Services\Marketing;
use App\Jobs\SendMarketingCampaignJob;
use App\Models\Marketing\MarketingAutomation;
use App\Models\Marketing\MarketingAutomationRun;
use App\Models\Marketing\MarketingCampaign;
use App\Models\Marketing\MarketingList;
use App\Models\Marketing\MarketingPromo;
use App\Services\Cannaiq\CannaiqClient;
use Illuminate\Support\Facades\Log;
/**
* AutomationRunner - Evaluates and executes marketing automations.
*
* Handles condition evaluation based on CannaiQ data and local inventory,
* then executes actions like creating promos and launching campaigns.
*/
class AutomationRunner
{
protected CannaiqClient $cannaiqClient;
protected array $runDetails = [];
public function __construct(CannaiqClient $cannaiqClient)
{
$this->cannaiqClient = $cannaiqClient;
}
/**
* Run an automation and return the run record.
*/
public function runAutomation(MarketingAutomation $automation): MarketingAutomationRun
{
$this->runDetails = [
'automation_id' => $automation->id,
'automation_name' => $automation->name,
'trigger_type' => $automation->trigger_type,
'condition_type' => $automation->condition_config['type'] ?? 'unknown',
'started_at' => now()->toIso8601String(),
];
// Start the run record
$run = MarketingAutomationRun::start($automation);
try {
// Evaluate conditions
$evaluationResult = $this->evaluateConditions($automation);
if (! $evaluationResult['conditions_met']) {
// Conditions not met - skip
$run->skip(
$evaluationResult['reason'] ?? 'Conditions not met',
array_merge($this->runDetails, [
'evaluation' => $evaluationResult,
])
);
$this->updateAutomationStatus($automation, 'skipped');
return $run;
}
// Execute actions
$actionResults = $this->executeActions($automation, $evaluationResult['context'] ?? []);
// Determine final status
$hasErrors = collect($actionResults)->contains('success', false);
$allFailed = collect($actionResults)->every('success', false);
if ($allFailed) {
$run->fail(
'All actions failed',
array_merge($this->runDetails, [
'evaluation' => $evaluationResult,
'actions' => $actionResults,
])
);
$this->updateAutomationStatus($automation, 'error');
} elseif ($hasErrors) {
$run->partial(
$this->buildSummary($actionResults),
array_merge($this->runDetails, [
'evaluation' => $evaluationResult,
'actions' => $actionResults,
])
);
$this->updateAutomationStatus($automation, 'partial');
} else {
$run->succeed(
$this->buildSummary($actionResults),
array_merge($this->runDetails, [
'evaluation' => $evaluationResult,
'actions' => $actionResults,
])
);
$this->updateAutomationStatus($automation, 'success');
}
return $run;
} catch (\Exception $e) {
Log::error('AutomationRunner: Exception during automation run', [
'automation_id' => $automation->id,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
$run->fail(
'Exception: '.$e->getMessage(),
array_merge($this->runDetails, [
'error' => $e->getMessage(),
'exception_class' => get_class($e),
])
);
$this->updateAutomationStatus($automation, 'error');
return $run;
}
}
/**
* Evaluate conditions based on condition_config type.
*/
protected function evaluateConditions(MarketingAutomation $automation): array
{
$conditionConfig = $automation->condition_config;
$conditionType = $conditionConfig['type'] ?? 'unknown';
return match ($conditionType) {
MarketingAutomation::CONDITION_COMPETITOR_OUT_OF_STOCK => $this->evaluateCompetitorOutOfStock($automation),
MarketingAutomation::CONDITION_SLOW_MOVER_CLEARANCE => $this->evaluateSlowMoverClearance($automation),
MarketingAutomation::CONDITION_NEW_STORE_LAUNCH => $this->evaluateNewStoreLaunch($automation),
default => [
'conditions_met' => false,
'reason' => "Unknown condition type: {$conditionType}",
],
};
}
/**
* Evaluate: Competitor is out of stock and we have inventory.
*/
protected function evaluateCompetitorOutOfStock(MarketingAutomation $automation): array
{
$config = $automation->condition_config;
$triggerConfig = $automation->trigger_config;
$category = $config['category'] ?? null;
$minInventory = $config['min_inventory_units'] ?? 30;
$minPriceAdvantage = $config['min_price_advantage'] ?? 0.1;
$storeScope = $triggerConfig['store_scope'] ?? 'all';
$brandIds = $triggerConfig['brand_ids'] ?? [];
// Get store external IDs to check
$storeIds = $this->getStoreExternalIds($automation->business_id, $storeScope);
if (empty($storeIds)) {
return [
'conditions_met' => false,
'reason' => 'No stores found for this business',
];
}
$opportunities = [];
foreach ($storeIds as $storeId) {
try {
// Get competitor snapshot from CannaiQ
$competitorData = $this->cannaiqClient->getStoreCompetitorSnapshot($storeId);
if (! empty($competitorData['error'])) {
continue;
}
// Check for out-of-stock competitors in our category
$outOfStockCompetitors = $this->findOutOfStockCompetitors(
$competitorData,
$category,
$minInventory,
$minPriceAdvantage
);
if (! empty($outOfStockCompetitors)) {
$opportunities[] = [
'store_id' => $storeId,
'competitors' => $outOfStockCompetitors,
'category' => $category,
];
}
} catch (\Exception $e) {
Log::warning('AutomationRunner: Error checking store', [
'store_id' => $storeId,
'error' => $e->getMessage(),
]);
}
}
if (empty($opportunities)) {
return [
'conditions_met' => false,
'reason' => 'No competitor out-of-stock opportunities found',
'stores_checked' => count($storeIds),
];
}
return [
'conditions_met' => true,
'reason' => count($opportunities).' store(s) have competitor out-of-stock opportunities',
'context' => [
'opportunities' => $opportunities,
'category' => $category,
],
];
}
/**
* Evaluate: Slow-moving inventory that needs clearance.
*/
protected function evaluateSlowMoverClearance(MarketingAutomation $automation): array
{
$config = $automation->condition_config;
$velocityThreshold = $config['velocity_30d_threshold'] ?? 5;
$minInventory = $config['min_inventory_units'] ?? 50;
$minDaysInStock = $config['min_days_in_stock'] ?? 30;
// Get slow movers from CannaiQ or local inventory
// For now, we'll stub this with CannaiQ product metrics
$storeIds = $this->getStoreExternalIds($automation->business_id, 'all');
$slowMovers = [];
foreach ($storeIds as $storeId) {
try {
$productMetrics = $this->cannaiqClient->getStoreProductMetrics($storeId, 100, 0);
if (! empty($productMetrics['error'])) {
continue;
}
$products = $productMetrics['products'] ?? [];
foreach ($products as $product) {
$velocity = $product['velocity_30d'] ?? 0;
$inventory = $product['inventory_units'] ?? 0;
$daysInStock = $product['days_in_stock'] ?? 0;
if ($velocity < $velocityThreshold
&& $inventory >= $minInventory
&& $daysInStock >= $minDaysInStock) {
$slowMovers[] = [
'store_id' => $storeId,
'product_id' => $product['id'] ?? null,
'product_name' => $product['name'] ?? 'Unknown',
'velocity_30d' => $velocity,
'inventory' => $inventory,
'days_in_stock' => $daysInStock,
];
}
}
} catch (\Exception $e) {
Log::warning('AutomationRunner: Error checking slow movers', [
'store_id' => $storeId,
'error' => $e->getMessage(),
]);
}
}
if (empty($slowMovers)) {
return [
'conditions_met' => false,
'reason' => 'No slow-moving products found matching criteria',
];
}
return [
'conditions_met' => true,
'reason' => count($slowMovers).' slow-moving product(s) found',
'context' => [
'slow_movers' => $slowMovers,
],
];
}
/**
* Evaluate: New store launch (first appearance of brand at a store).
*/
protected function evaluateNewStoreLaunch(MarketingAutomation $automation): array
{
$config = $automation->condition_config;
$windowDays = $config['first_appearance_window_days'] ?? 7;
// Check for new store appearances in CannaiQ
// This would typically involve comparing current store list vs. cached/stored list
// For now, stub this with a simplified check
$storeIds = $this->getStoreExternalIds($automation->business_id, 'all');
// Get previously known stores from meta or a tracking table
$knownStores = $automation->meta['known_stores'] ?? [];
$newStores = array_diff($storeIds, $knownStores);
if (empty($newStores)) {
return [
'conditions_met' => false,
'reason' => 'No new store appearances found',
];
}
// Update known stores in meta
$automation->update([
'meta' => array_merge($automation->meta ?? [], [
'known_stores' => $storeIds,
'last_store_check' => now()->toIso8601String(),
]),
]);
return [
'conditions_met' => true,
'reason' => count($newStores).' new store(s) detected',
'context' => [
'new_stores' => array_values($newStores),
],
];
}
/**
* Execute actions based on action_config.
*/
protected function executeActions(MarketingAutomation $automation, array $context): array
{
$actionConfig = $automation->action_config;
$results = [];
// Create promo if configured
if (! empty($actionConfig['create_promo'])) {
$results['promo'] = $this->createPromo($automation, $actionConfig['create_promo'], $context);
}
// Create campaign if configured
if (! empty($actionConfig['create_campaign'])) {
$promoId = $results['promo']['promo_id'] ?? null;
$results['campaign'] = $this->createCampaign(
$automation,
$actionConfig['create_campaign'],
$context,
$promoId
);
}
return $results;
}
/**
* Create a promo based on action config.
*/
protected function createPromo(MarketingAutomation $automation, array $promoConfig, array $context): array
{
try {
$promoType = $promoConfig['promo_type'] ?? 'flash_bogo';
$durationHours = $promoConfig['duration_hours'] ?? 24;
$discountPercent = $promoConfig['discount_percent'] ?? null;
// Map automation promo types to MarketingPromo types
$typeMapping = [
'flash_bogo' => MarketingPromo::TYPE_BOGO,
'percent_off' => MarketingPromo::TYPE_PERCENT_OFF,
'clearance' => MarketingPromo::TYPE_PERCENT_OFF,
'launch_special' => MarketingPromo::TYPE_BOGO,
];
$promoTypeDb = $typeMapping[$promoType] ?? MarketingPromo::TYPE_PERCENT_OFF;
// Build promo name from context
$promoName = $this->buildPromoName($automation, $context);
// Build config
$config = [];
if ($discountPercent) {
$config['discount_value'] = $discountPercent;
}
// Determine store scope from context
$storeExternalId = null;
if (! empty($context['opportunities'][0]['store_id'])) {
$storeExternalId = $context['opportunities'][0]['store_id'];
}
$promo = MarketingPromo::create([
'business_id' => $automation->business_id,
'store_external_id' => $storeExternalId,
'name' => $promoName,
'type' => $promoTypeDb,
'config' => $config,
'status' => MarketingPromo::STATUS_ACTIVE,
'starts_at' => now(),
'ends_at' => now()->addHours($durationHours),
'description' => "Auto-generated by automation: {$automation->name}",
'sms_copy' => $this->buildSmsCopy($automation, $context),
'email_copy' => $this->buildEmailCopy($automation, $context),
]);
return [
'success' => true,
'promo_id' => $promo->id,
'promo_name' => $promo->name,
];
} catch (\Exception $e) {
Log::error('AutomationRunner: Failed to create promo', [
'automation_id' => $automation->id,
'error' => $e->getMessage(),
]);
return [
'success' => false,
'error' => $e->getMessage(),
];
}
}
/**
* Create a campaign based on action config.
*/
protected function createCampaign(
MarketingAutomation $automation,
array $campaignConfig,
array $context,
?int $promoId = null
): array {
try {
$channels = $campaignConfig['channels'] ?? ['email'];
$listType = $campaignConfig['list_type'] ?? 'consumers';
$sendMode = $campaignConfig['send_mode'] ?? 'immediate';
$subjectTemplate = $campaignConfig['subject_template'] ?? 'Special Offer';
$smsBodyTemplate = $campaignConfig['sms_body_template'] ?? '';
$emailBodyTemplate = $campaignConfig['email_body_template'] ?? '';
// Get or create a marketing list for this business
$list = $this->getOrCreateMarketingList($automation->business_id, $listType);
if (! $list) {
return [
'success' => false,
'error' => 'No marketing list found for this business',
];
}
$results = [];
foreach ($channels as $channel) {
$campaignName = "{$automation->name} - ".ucfirst($channel).' - '.now()->format('M j, Y');
$campaignData = [
'business_id' => $automation->business_id,
'name' => $campaignName,
'channel' => $channel,
'status' => MarketingCampaign::STATUS_DRAFT,
'marketing_list_id' => $list->id,
'source_type' => MarketingCampaign::SOURCE_AUTOMATION,
'source_id' => $automation->id,
];
// Fill content based on channel
if ($channel === 'email') {
$campaignData['subject'] = $this->replacePlaceholders($subjectTemplate, $context);
$campaignData['email_body_html'] = $this->replacePlaceholders($emailBodyTemplate, $context);
} elseif ($channel === 'sms') {
$campaignData['sms_body'] = $this->replacePlaceholders($smsBodyTemplate, $context);
}
// Link to promo if available
if ($promoId) {
$campaignData['source_type'] = MarketingCampaign::SOURCE_PROMO;
$campaignData['source_id'] = $promoId;
}
$campaign = MarketingCampaign::create($campaignData);
// Handle send mode
if ($sendMode === 'immediate') {
$campaign->update(['status' => MarketingCampaign::STATUS_SENDING]);
SendMarketingCampaignJob::dispatch($campaign);
$results[$channel] = [
'success' => true,
'campaign_id' => $campaign->id,
'status' => 'sending',
];
} elseif ($sendMode === 'schedule') {
$scheduleOffset = $campaignConfig['schedule_offset_hours'] ?? 1;
$campaign->schedule(now()->addHours($scheduleOffset));
$results[$channel] = [
'success' => true,
'campaign_id' => $campaign->id,
'status' => 'scheduled',
'send_at' => $campaign->send_at->toIso8601String(),
];
} else {
$results[$channel] = [
'success' => true,
'campaign_id' => $campaign->id,
'status' => 'draft',
];
}
}
return [
'success' => true,
'campaigns' => $results,
];
} catch (\Exception $e) {
Log::error('AutomationRunner: Failed to create campaign', [
'automation_id' => $automation->id,
'error' => $e->getMessage(),
]);
return [
'success' => false,
'error' => $e->getMessage(),
];
}
}
/**
* Get store external IDs for a business.
*/
protected function getStoreExternalIds(int $businessId, string $scope): array
{
// Get stores from cannaiq_store_metrics table or configured store list
// For now, we'll get from the CannaiQ cached data
$stores = \DB::table('cannaiq_store_metrics')
->where('business_id', $businessId)
->pluck('store_external_id')
->unique()
->toArray();
return $stores;
}
/**
* Find out-of-stock competitors from CannaiQ data.
*/
protected function findOutOfStockCompetitors(
array $competitorData,
?string $category,
int $minInventory,
float $minPriceAdvantage
): array {
$outOfStock = [];
// Parse competitor snapshot data
$competitors = $competitorData['competitors'] ?? [];
foreach ($competitors as $competitor) {
$competitorProducts = $competitor['products'] ?? [];
foreach ($competitorProducts as $product) {
$productCategory = $product['category'] ?? null;
// Skip if category filter specified and doesn't match
if ($category && $productCategory !== $category) {
continue;
}
$competitorStock = $product['competitor_inventory'] ?? 0;
$ourStock = $product['our_inventory'] ?? 0;
$priceAdvantage = $product['price_advantage'] ?? 0;
// Check if competitor is out of stock and we have inventory
if ($competitorStock === 0 && $ourStock >= $minInventory && $priceAdvantage >= $minPriceAdvantage) {
$outOfStock[] = [
'competitor_name' => $competitor['name'] ?? 'Unknown',
'product_name' => $product['name'] ?? 'Unknown',
'our_inventory' => $ourStock,
'price_advantage' => $priceAdvantage,
];
}
}
}
return $outOfStock;
}
/**
* Get or create a marketing list for campaigns.
*/
protected function getOrCreateMarketingList(int $businessId, string $listType): ?MarketingList
{
// Try to find existing list
$list = MarketingList::where('business_id', $businessId)
->where('type', 'static')
->first();
if ($list) {
return $list;
}
// Create a default list if none exists
return MarketingList::create([
'business_id' => $businessId,
'name' => 'All Contacts',
'type' => 'static',
'description' => 'Auto-created list for automation campaigns',
]);
}
/**
* Build promo name from automation and context.
*/
protected function buildPromoName(MarketingAutomation $automation, array $context): string
{
$conditionType = $automation->condition_config['type'] ?? 'unknown';
return match ($conditionType) {
MarketingAutomation::CONDITION_COMPETITOR_OUT_OF_STOCK => 'Flash Sale - '.now()->format('M j'),
MarketingAutomation::CONDITION_SLOW_MOVER_CLEARANCE => 'Clearance - '.now()->format('M j'),
MarketingAutomation::CONDITION_NEW_STORE_LAUNCH => 'Welcome Special - '.now()->format('M j'),
default => $automation->name.' - '.now()->format('M j'),
};
}
/**
* Build SMS copy from context.
*/
protected function buildSmsCopy(MarketingAutomation $automation, array $context): string
{
$template = $automation->action_config['create_campaign']['sms_body_template']
?? '🔥 Special deal today! Visit us for exclusive savings.';
return $this->replacePlaceholders($template, $context);
}
/**
* Build email copy from context.
*/
protected function buildEmailCopy(MarketingAutomation $automation, array $context): string
{
$template = $automation->action_config['create_campaign']['email_body_template']
?? '<p>Check out our latest special offers!</p>';
return $this->replacePlaceholders($template, $context);
}
/**
* Replace placeholders in templates.
*/
protected function replacePlaceholders(string $template, array $context): string
{
$replacements = [
'{store_name}' => $context['opportunities'][0]['store_id'] ?? 'our store',
'{product_name}' => $context['opportunities'][0]['competitors'][0]['product_name']
?? $context['slow_movers'][0]['product_name']
?? 'featured products',
'{promo_text}' => 'Limited time offer!',
'{brand_name}' => 'Our Brand',
'{category}' => $context['category'] ?? 'cannabis',
];
return str_replace(array_keys($replacements), array_values($replacements), $template);
}
/**
* Build a human-readable summary of action results.
*/
protected function buildSummary(array $actionResults): string
{
$parts = [];
if (! empty($actionResults['promo']['success'])) {
$parts[] = "Created promo: {$actionResults['promo']['promo_name']}";
}
if (! empty($actionResults['campaign']['success'])) {
$campaigns = $actionResults['campaign']['campaigns'] ?? [];
$campaignCount = count($campaigns);
$parts[] = "Created {$campaignCount} campaign(s)";
foreach ($campaigns as $channel => $result) {
if ($result['success']) {
$parts[] = ucfirst($channel).": {$result['status']}";
}
}
}
return implode('. ', $parts) ?: 'No actions executed';
}
/**
* Update automation status after run.
*/
protected function updateAutomationStatus(MarketingAutomation $automation, string $status): void
{
$automation->update([
'last_run_at' => now(),
'last_status' => $status,
]);
}
}

View File

@@ -0,0 +1,376 @@
<?php
namespace App\Services\Marketing;
use App\Models\Cannaiq\ProductMetric;
use App\Models\Cannaiq\StoreMetric;
use App\Services\Cannaiq\CannaiqClient;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
/**
* Marketing Intelligence Service
*
* Orchestrates market intelligence data from CannaiQ, handling:
* - Data fetching and caching
* - Store and product metric aggregation
* - Competitor analysis
* - Trend calculations
*/
class MarketingIntelligenceService
{
protected CannaiqClient $client;
protected int $cacheTtl;
public function __construct(CannaiqClient $client)
{
$this->client = $client;
$this->cacheTtl = config('services.cannaiq.cache_ttl', 7200);
}
/**
* Get store-level intelligence metrics
*/
public function getStoreIntelligence(int $businessId, string $storeExternalId): array
{
$cacheKey = "cannaiq:store:{$businessId}:{$storeExternalId}";
return Cache::remember($cacheKey, $this->cacheTtl, function () use ($businessId, $storeExternalId) {
// Check for cached snapshot in database
$cached = StoreMetric::forBusiness($businessId)
->forStore($storeExternalId)
->latest()
->recent(24) // Within last 24 hours
->first();
if ($cached) {
return $this->formatStoreMetrics($cached);
}
// Fetch fresh data from CannaiQ (store metrics endpoint)
$data = $this->client->getStoreMetrics($storeExternalId);
if (isset($data['error'])) {
return [
'error' => true,
'message' => $data['message'] ?? 'Failed to fetch store metrics',
'metrics' => $this->getDefaultStoreMetrics(),
];
}
// Cache to database
$metric = $this->cacheStoreMetric($businessId, $storeExternalId, $data);
return $this->formatStoreMetrics($metric);
});
}
/**
* Get product-level intelligence metrics for a store
*/
public function getProductIntelligence(int $businessId, string $storeExternalId, int $limit = 50): array
{
$cacheKey = "cannaiq:products:{$businessId}:{$storeExternalId}:{$limit}";
return Cache::remember($cacheKey, $this->cacheTtl, function () use ($businessId, $storeExternalId, $limit) {
// Check for cached products in database
$cached = ProductMetric::forBusiness($businessId)
->forStore($storeExternalId)
->recent(24)
->limit($limit)
->get();
if ($cached->isNotEmpty()) {
return [
'products' => $cached->map(fn ($m) => $this->formatProductMetric($m))->toArray(),
'cached_at' => $cached->first()->snapshot_date->toIso8601String(),
];
}
// Fetch fresh data from CannaiQ
$data = $this->client->getStoreProductMetrics($storeExternalId, $limit);
if (isset($data['error'])) {
return [
'error' => true,
'message' => $data['message'] ?? 'Failed to fetch product metrics',
'products' => [],
];
}
// Cache products to database
$products = $data['products'] ?? [];
$metrics = [];
foreach ($products as $product) {
$metric = $this->cacheProductMetric($businessId, $storeExternalId, $product);
$metrics[] = $this->formatProductMetric($metric);
}
return [
'products' => $metrics,
'total' => $data['meta']['total'] ?? count($metrics),
'cached_at' => now()->toIso8601String(),
];
});
}
/**
* Get competitor snapshot for a store's market area
*/
public function getCompetitorSnapshot(int $businessId, string $storeExternalId): array
{
$cacheKey = "cannaiq:competitors:{$businessId}:{$storeExternalId}";
return Cache::remember($cacheKey, $this->cacheTtl, function () use ($storeExternalId) {
$data = $this->client->getStoreCompetitorSnapshot($storeExternalId);
if (isset($data['error'])) {
return [
'error' => true,
'message' => $data['message'] ?? 'Failed to fetch competitor data',
'competitors' => [],
];
}
return [
'target_store' => $data['target_store'] ?? null,
'competitors' => $this->processCompetitors($data['competitors'] ?? []),
'snapshot_time' => $data['snapshot_time'] ?? now()->toIso8601String(),
];
});
}
/**
* Get aggregated market trends
*/
public function getMarketTrends(int $businessId, string $storeExternalId): array
{
// Get historical metrics from database
$storeMetrics = StoreMetric::forBusiness($businessId)
->forStore($storeExternalId)
->recent(30) // Last 30 days
->orderBy('snapshot_date')
->get();
if ($storeMetrics->isEmpty()) {
return [
'trends' => [],
'message' => 'Insufficient data for trend analysis',
];
}
return [
'pricing_trend' => $this->calculatePricingTrend($storeMetrics),
'inventory_trend' => $this->calculateInventoryTrend($storeMetrics),
'competitor_activity' => $this->calculateCompetitorActivity($storeMetrics),
'data_points' => $storeMetrics->count(),
'date_range' => [
'start' => $storeMetrics->first()->snapshot_date->toDateString(),
'end' => $storeMetrics->last()->snapshot_date->toDateString(),
],
];
}
/**
* Refresh all intelligence data for a business
*/
public function refreshIntelligence(int $businessId, string $storeExternalId): array
{
// Clear caches
Cache::forget("cannaiq:store:{$businessId}:{$storeExternalId}");
Cache::forget("cannaiq:products:{$businessId}:{$storeExternalId}:50");
Cache::forget("cannaiq:competitors:{$businessId}:{$storeExternalId}");
$results = [
'store' => false,
'products' => false,
'competitors' => false,
];
try {
// Refresh store metrics
$storeData = $this->client->getStoreMetrics($storeExternalId);
if (! isset($storeData['error'])) {
$this->cacheStoreMetric($businessId, $storeExternalId, $storeData);
$results['store'] = true;
}
// Refresh product metrics
$productData = $this->client->getStoreProductMetrics($storeExternalId, 100);
if (! isset($productData['error'])) {
foreach ($productData['products'] ?? [] as $product) {
$this->cacheProductMetric($businessId, $storeExternalId, $product);
}
$results['products'] = true;
}
// Refresh competitor snapshot
$competitorData = $this->client->getStoreCompetitorSnapshot($storeExternalId);
if (! isset($competitorData['error'])) {
$results['competitors'] = true;
}
} catch (\Exception $e) {
Log::error('MarketingIntelligence: Refresh failed', [
'business_id' => $businessId,
'store_id' => $storeExternalId,
'error' => $e->getMessage(),
]);
}
return $results;
}
/**
* Cache store metric to database
*/
protected function cacheStoreMetric(int $businessId, string $storeExternalId, array $data): StoreMetric
{
return StoreMetric::create([
'business_id' => $businessId,
'store_external_id' => $storeExternalId,
'snapshot_date' => now(),
'raw_payload' => $data,
]);
}
/**
* Cache product metric to database
*/
protected function cacheProductMetric(int $businessId, string $storeExternalId, array $data): ProductMetric
{
return ProductMetric::updateOrCreate(
[
'business_id' => $businessId,
'store_external_id' => $storeExternalId,
'product_external_id' => $data['id'] ?? $data['product_id'] ?? uniqid(),
'snapshot_date' => now()->toDateString(),
],
[
'raw_payload' => $data,
]
);
}
/**
* Format store metric for API response
*/
protected function formatStoreMetrics(StoreMetric $metric): array
{
return [
'store_name' => $metric->store_name,
'product_count' => $metric->product_count,
'average_price' => $metric->average_price,
'pricing_position' => $metric->raw_payload['pricing_position'] ?? null,
'market_share' => $metric->raw_payload['market_share'] ?? null,
'snapshot_date' => $metric->snapshot_date->toIso8601String(),
'raw' => $metric->raw_payload,
];
}
/**
* Format product metric for API response
*/
protected function formatProductMetric(ProductMetric $metric): array
{
return [
'product_id' => $metric->product_external_id,
'name' => $metric->product_name,
'brand' => $metric->brand_name,
'category' => $metric->category,
'current_price' => $metric->current_price,
'original_price' => $metric->original_price,
'is_on_sale' => $metric->is_on_sale,
'discount_percent' => $metric->discount_percent,
'in_stock' => $metric->in_stock,
'thc_percent' => $metric->thc_percent,
'snapshot_date' => $metric->snapshot_date->toDateString(),
];
}
/**
* Process competitor data
*/
protected function processCompetitors(array $competitors): array
{
return collect($competitors)->map(function ($competitor) {
return [
'id' => $competitor['id'] ?? null,
'name' => $competitor['name'] ?? 'Unknown',
'slug' => $competitor['slug'] ?? null,
'distance' => $competitor['distance'] ?? null,
'product_count' => $competitor['products_count'] ?? 0,
'average_price' => $competitor['average_price'] ?? null,
];
})->toArray();
}
/**
* Calculate pricing trend from historical data
*/
protected function calculatePricingTrend($metrics): array
{
$prices = $metrics->pluck('raw_payload.average_price')->filter()->values();
if ($prices->count() < 2) {
return ['direction' => 'stable', 'change' => 0];
}
$first = $prices->first();
$last = $prices->last();
$change = $first > 0 ? (($last - $first) / $first) * 100 : 0;
return [
'direction' => $change > 1 ? 'up' : ($change < -1 ? 'down' : 'stable'),
'change' => round($change, 2),
];
}
/**
* Calculate inventory trend from historical data
*/
protected function calculateInventoryTrend($metrics): array
{
$counts = $metrics->pluck('raw_payload.products_count')->filter()->values();
if ($counts->count() < 2) {
return ['direction' => 'stable', 'change' => 0];
}
$first = $counts->first();
$last = $counts->last();
$change = $first > 0 ? (($last - $first) / $first) * 100 : 0;
return [
'direction' => $change > 5 ? 'up' : ($change < -5 ? 'down' : 'stable'),
'change' => round($change, 2),
];
}
/**
* Calculate competitor activity level
*/
protected function calculateCompetitorActivity($metrics): array
{
// Placeholder - would analyze competitor snapshots over time
return [
'level' => 'moderate',
'promo_activity' => 'normal',
];
}
/**
* Get default store metrics when data is unavailable
*/
protected function getDefaultStoreMetrics(): array
{
return [
'store_name' => null,
'product_count' => 0,
'average_price' => null,
'pricing_position' => null,
'market_share' => null,
];
}
}

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