Compare commits

...

84 Commits

Author SHA1 Message Date
kelly
c92cd230d5 fix: correct BannerAdResource navigationGroup type hint
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2025-12-17 22:33:07 -07:00
kelly
bcbfdd3c91 feat: add banner ad system with zones, scheduling, and analytics
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Banner Ad System:
- BannerAd model with scheduling (starts_at, ends_at)
- BannerAdEvent model for impression/click tracking
- BannerAdDailyStat model for analytics rollups
- BannerAdZone enum (6 zones: hero, leaderboard, sidebar, inline, brand, deals)
- BannerAdStatus enum (draft, active, scheduled, paused, expired)

Service & Controller:
- BannerAdService with weighted random rotation, caching
- BannerAdController for click tracking and image serving
- Routes for /ads/click, /ads/impression, /images/banner-ad

Filament Admin:
- Full CRUD resource at /admin/banner-ads
- Image upload to MinIO
- Status/zone filters
- Analytics display (impressions, clicks, CTR)

Display Components:
- <x-banner-ad zone="..." /> Blade component
- Automatic impression tracking
- Click tracking via redirect
- Sponsored badge overlay

View Placements:
- Marketplace homepage: leaderboard + sidebar
- Brand page: banner below breadcrumbs
- Deals page: hero banner

Background Jobs:
- UpdateBannerAdStatuses: activate scheduled, expire ended (every minute)
- RollupBannerAdStats: daily aggregation + event cleanup
2025-12-17 22:18:27 -07:00
kelly
3c21093e66 fix: eager load brand relationship for product cards on brand page
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2025-12-17 21:52:57 -07:00
kelly
e4e0a19873 feat: redesign brand page with Amazon/Shopify style UI
- Hero banner section with gradient overlay and logo
- Brand details section below hero (stats, social links, description)
- Featured products horizontal scroll section
- Menu/category tabs for product filtering
- Grid/list view toggle with localStorage persistence
- Reusable product-card component for all product displays
- Breadcrumb navigation
2025-12-17 21:52:31 -07:00
kelly
5b0503abf5 fix: resolve marketplace query issues
- Fix PostgreSQL having clause error by using collection filtering instead
- Use category_id with ProductCategory model instead of deprecated category string
- Eager load category relationship to prevent lazy loading violations
- Update view to use category_id parameter and ProductCategory names
2025-12-17 21:48:18 -07:00
kelly
cc8aab7ee1 fix: add PWA partial to auth-layout component
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
PWA was not working on login/register pages because the auth-layout
component was missing the @include('partials.pwa') directive.
2025-12-17 21:25:04 -07:00
kelly
2c1f7d093f feat: enterprise accounting UI harmonization for orders/invoices
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
- Add 3-column header layout (Buyer | Seller | Document Info) to:
  - Order create page (new)
  - Invoice create page (updated)
  - Order show page (updated with units/cases, item comments)
  - Invoice show page (updated with seller info, units/cases)
  - Quote show page (updated with seller info, units/cases)

- Add seller-initiated order creation:
  - New /orders/create route and view
  - Orders track created_by (buyer/seller)
  - New Order button on orders index

- Add ping pong order flow feature:
  - ping_pong_enabled on businesses table
  - is_ping_pong toggle per order
  - Admin toggle in Business > Suite Settings

- Add item comments per line item:
  - item_comment field on order_items, crm_invoice_items, crm_quote_items
  - Inline edit UI on order show page

- UI improvements:
  - Units/cases display (X UNITS / Y CASES)
  - Live totals in document headers
  - Consistent styling across all document types
2025-12-17 19:01:13 -07:00
kelly
11a07692ad feat: add CannaiQ product mapping
- Add product_cannaiq_mappings pivot table (many-to-many)
- Add ProductCannaiqMapping model
- Add cannaiqMappings relationship to Product model
- Add mapping page at /s/{business}/brands/{brand}/cannaiq
- Add map/unmap API endpoints
- Update brand settings CannaiQ section with searchable brand dropdown
- Search CannaiQ products and map to Hub products
2025-12-17 18:58:08 -07:00
kelly
05754c9d5b feat: add CannaiQ brand integration
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
- Add cannaiq_brand_key column to brands table for API integration
- Add Brand model methods: isCannaiqConnected(), connectToCannaiq(), disconnectFromCannaiq()
- Add CannaiQ badge to brand tiles on index page
- Add CannaiQ Integration settings section in brand dashboard
- Add connect/disconnect routes and controller methods
2025-12-17 18:25:37 -07:00
kelly
e26da88f22 fix: correct location_id validation in InvoiceController
Change validation from nullable|integer to exists:locations,id
to properly validate against the locations table.
2025-12-17 16:49:13 -07:00
kelly
b703b27676 feat: PWA install prompt, update notifications, and CRM fixes
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
PWA:
- Add install prompt toast (appears after 30 seconds)
- Remembers dismissal for 7 days
- Fix: check for waiting SW on page load for update banner

CRM fixes:
- QuoteController: eager load items.product.brand to prevent N+1
- InvoiceController: change location_id validation to nullable integer
2025-12-17 16:43:36 -07:00
kelly
5dffd96187 feat: add select all checkbox for permission groups in user edit
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2025-12-17 16:22:49 -07:00
kelly
9ff1f2b37b fix: service worker self-destructs on localhost
Some checks failed
ci/woodpecker/push/ci Pipeline failed
SW now checks hostname at startup and unregisters itself on localhost/127.0.0.1.
Prevents dev environment issues with Vite HMR and double-click navigation.
2025-12-17 16:21:34 -07:00
kelly
23ad9f2824 fix: resolve Crystal's issues #14, #11, #8
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Issue #14 - Calendar Edit modal disappears:
- Store event reference before closing detail modal
- closeDetail() was setting selectedEvent to null before openEditModal used it

Issue #11 - Quote edit LazyLoadingViolation:
- CrmQuoteItem booted hooks now check relationLoaded() before accessing quote
- Prevents lazy loading when quote relation not eager-loaded

Issue #8 - Invoice Cannot Submit (location_id column missing):
- Add migration to add location_id column to crm_invoices table
- Make crm_invoice_items.name nullable (controller doesn't provide it)
- CrmInvoiceItem has same lazy loading fix
2025-12-17 16:09:09 -07:00
kelly
b90cb829c9 Merge branch 'fix/product-description-emoji-import' into develop
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2025-12-17 15:20:52 -07:00
kelly
69d9174314 Merge branch 'fix/crystal-issues' into develop
# Conflicts:
#	app/Http/Controllers/Seller/Crm/ThreadController.php
2025-12-17 15:14:42 -07:00
kelly
c350ecbb3c Merge branch 'feat/dashboard-pwa-enhancements' into develop
# Conflicts:
#	.woodpecker/.ci.yml
2025-12-17 15:13:43 -07:00
kelly
06098c7013 fix: prevent lazy loading violations in CRM item models and calendar edit 2025-12-17 15:13:11 -07:00
kelly
709321383c fix(ci): clear bootstrap cache and verify test command
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
- Clear bootstrap/cache/*.php to force package discovery
- Add verification step to confirm artisan test is available
2025-12-17 13:55:48 -07:00
kelly
7506466c38 fix(ci): rm -rf vendor before composer install
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Ensures dev dependencies like parallel-lint are installed fresh
instead of using stale cached vendor from Docker image.
2025-12-17 13:52:42 -07:00
kelly
c84455a11b fix: force unregister service workers on localhost
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Actively unregister existing service workers on localhost instead of just
skipping registration. Fixes double-click navigation issue caused by
stale SW intercepting requests during local development.
2025-12-17 13:12:34 -07:00
kelly
2e7fff135c fix(ci): use development env and PostgreSQL for unit tests
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Change APP_ENV from production to development (installs dev dependencies)
- Change unit tests from SQLite to PostgreSQL (matches feature tests)
- Fixes parallel-lint not found error
2025-12-17 13:12:12 -07:00
kelly
a1a8e3ee9c fix(ci): point feature tests to primary DB (10.100.7.50)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
10.100.6.50 is a read-only replica, tests need write access.
2025-12-17 13:10:14 -07:00
kelly
72ab5d8baa fix(tests): move service tests to Feature suite
Some checks failed
ci/woodpecker/push/ci Pipeline failed
These tests use database factories which require the DB schema.
Unit tests run with SQLite in-memory (no migrations), so tests
that need real DB records must be in Feature suite (PostgreSQL).
2025-12-17 13:08:58 -07:00
kelly
fc715c6022 feat: add DBA (Doing Business As) entity system for sellers
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline failed
Implement complete DBA management allowing businesses to operate under
multiple trade names with separate addresses, licenses, banking info,
and invoice branding.

Database:
- Create business_dbas table with encrypted bank/tax fields
- Add dba_id foreign key to crm_invoices

Models:
- BusinessDba model with encrypted casts, auto-slug, single-default enforcement
- Business model: dbas(), activeDbas(), defaultDba(), getDbaForInvoice()
- CrmInvoice model: dba() relationship, getSellerDisplayInfo() method

Seller UI:
- DbaController with full CRUD + set-default + toggle-active
- Index/create/edit views using DaisyUI
- Trade Names card added to settings index

Admin:
- DbasRelationManager for BusinessResource in Filament

Migration:
- MigrateDbaData command to convert existing dba_name fields
2025-12-17 12:50:07 -07:00
kelly
32a00493f8 fix(ci): explicitly set DB_CONNECTION=pgsql in phpunit.xml
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
CI was falling back to sqlite because DB_CONNECTION wasn't set.
This caused all database-dependent tests to fail with 'no such table'.
2025-12-17 10:37:52 -07:00
kelly
ffc9405c34 fix(ci): clean vendor dir before composer install
Some checks failed
ci/woodpecker/pr/ci Pipeline failed
Ensures dev dependencies like parallel-lint are installed
fresh instead of potentially using cached vendor directory
that was built without dev deps.
2025-12-17 09:59:12 -07:00
kelly
732c46cabb feat: dashboard UX improvements and mobile PWA enhancements
Some checks failed
ci/woodpecker/pr/ci Pipeline failed
Dashboard fixes:
- Fix KPI card label truncation (remove truncate class)
- Fix pipeline snapshot bug (quotes → deals reference)
- Add actionable empty states with CTAs
- Add inventory alerts widget to sidebar
- Add pipeline velocity metrics widget
- Add real-time updates via Reverb (new orders, deal changes)
- Add pull-to-refresh for mobile

Mobile PWA:
- Add mobile bottom navigation (Home, Inbox, Orders, Pipeline)
- Add PWA install prompt with dismissible banner
- Add pull-to-refresh gesture support

Deals kanban:
- Add days-in-stage indicator to deal cards
- Improve mobile scrolling with snap-to-column
- Add pull-to-refresh support

New files:
- app/Events/NewOrderReceived.php
- app/Events/DealStageChanged.php
- resources/views/components/mobile-bottom-nav.blade.php
- resources/views/components/pwa-install-prompt.blade.php
- resources/views/components/pull-to-refresh.blade.php
- resources/views/components/dashboard/inventory-alerts.blade.php
- resources/views/components/dashboard/pipeline-velocity.blade.php
2025-12-17 09:42:27 -07:00
kelly
1906a28347 fix: skip service worker registration on localhost
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Service worker interferes with Vite HMR during local development,
causing navigation issues (double-click required). Now only registers
on production domains.
2025-12-17 02:42:07 -07:00
kelly
5de445d1cf fix: remove redundant click listener from sidebar scroll save
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Simplified scroll position save - only save on scroll events, not link clicks.
2025-12-17 02:39:54 -07:00
kelly
9855d41869 fix: prevent click propagation on sidebar menu items
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Added @click.stop to sidebar-item links to ensure clicks don't
propagate to parent toggle buttons.
2025-12-17 02:39:25 -07:00
kelly
319c5cc4d5 feat: add chat icon to topbar with unread message indicator
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Add chat/inbox icon between calendar and tasks
- Show green badge with unread thread count
- Links to CRM inbox page
2025-12-17 02:29:34 -07:00
kelly
44af326ebb fix: Conversations menu item now links to unified inbox
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Changed connect_conversations and crm_inbox routes from
seller.business.crm.threads.index to seller.business.crm.inbox
2025-12-17 02:29:12 -07:00
kelly
4f4e96dd84 style: make agent status widget more compact with badge style
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Use badge-sm instead of btn-xs
- Smaller avatar (w-6)
- Reduced padding (px-3 py-2)
- Removed online team members counter
- Smaller dropdown menu
2025-12-17 02:26:55 -07:00
kelly
fcc428b9f1 feat: add email engagement Activity logging and Brand Portal inbox filtering
- Add email.opened, email.clicked, email.bounced activity types
- Log Activity on first email open/click in EmailInteraction model
- Update BrandPortalController inbox to filter threads by linked brands
- Uses scopeForBrandPortal() on CrmThread for proper access control
2025-12-17 02:26:30 -07:00
kelly
fc9580470e feat: add logo/home link to inbox icon rail
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Added clickable logo at top of left icon rail
- Links to business dashboard
- Uses PWA icon (192x192 square)
2025-12-17 02:25:45 -07:00
kelly
bc9aaf745d feat: add Cannabrands logo to offline page
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Downloaded logo from cloud share
- Replaced icon with clickable logo linking to home
- Logo scales responsively (max 280px)
2025-12-17 02:23:10 -07:00
kelly
9df385e2e1 feat: complete omnichannel inbox real-time features
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Fix broadcast event names (.message.new, .typing, .thread.updated, .agent.status)
- Add / trigger for quick replies in composer
- Include agent status widget in inbox header
- Pass team member statuses from AgentStatus table
- Update useQuickReply to remove / trigger when inserting
2025-12-17 02:21:56 -07:00
kelly
102b2c1803 feat: add offline fallback page for PWA
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Created offline.html with connection status indicator
- Updated service worker to precache offline page
- Added fallback handler for navigation requests when offline
- Auto-reload when connection is restored
- Bumped cache version to v2
2025-12-17 02:18:43 -07:00
kelly
6705a8916a feat: use dispensary icon as platform favicon
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Add SVG favicon with dispensary icon (cannabis leaf + storefront)
- Convert to ICO for browser compatibility
- Remove customizable favicon options from all layouts
- Standardize favicon across all layout files
2025-12-17 02:17:12 -07:00
kelly
3e71c35c9e feat: add omnichannel inbox with team chat and buyer context
- Redesign inbox with Chatwoot-style icon rail navigation
- Add team chat infrastructure (TeamConversation, TeamMessage models)
- Add buyer context tracking (current page, cart, recently viewed)
- Show live buyer context in conversation sidebar
- Add chat request status fields to CrmThread for buyer requests
- Add broadcast channels for real-time team messaging
2025-12-17 02:15:23 -07:00
kelly
c772431475 fix: PWA manifest icons - separate 'any' and 'maskable' purposes
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Chrome requires icons with separate purpose values, not combined.
Added distinct entries for both 'any' and 'maskable' purposes.
2025-12-17 02:14:28 -07:00
kelly
c6b8d76373 fix: sidebar menu items require single click (not double)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
DaisyUI v5 collapse checkbox was intercepting clicks on menu items.
Fixed by:
- Constraining checkbox height to title area only
- Adding z-index to collapse-title and collapse-content
- Ensuring clickable elements are above the checkbox overlay
2025-12-17 02:11:17 -07:00
kelly
6ce095c60a feat: redesign CRM inbox with Chatwoot-style dual sidebar layout
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
- Add left icon rail for navigation (inbox views, channel filters, team)
- Move channel and status filters from top bar to sidebar icons
- Add sliding team panel showing online/offline coworkers
- Add presence tracking with online status indicators
- Improve thread list with channel badges on avatars
- Streamline conversation header with quick action buttons
- Add agent status selector in icon rail
- Integrate heartbeat for presence tracking
2025-12-17 01:50:32 -07:00
kelly
45ed3818fa fix: chat status buttons now work - prevent click propagation
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Added @click.stop to prevent dropdown from closing when clicking
status buttons, and type='button' to prevent form submission.
2025-12-17 01:13:26 -07:00
kelly
461b37ab30 fix: status filter on accounts page now filters by is_active
Some checks failed
ci/woodpecker/push/ci Pipeline failed
The dropdown offers active/inactive but controller was filtering by
status column (approval status). Now filters by is_active boolean
while still only showing approved accounts.
2025-12-17 01:11:57 -07:00
kelly
88167995b1 fix: use dispensary icon for customer column on orders page
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-17 01:09:58 -07:00
kelly
e8622765a2 fix: replace building SVG icons with dispensary.svg across all views
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Replaced all instances of building icon SVG path with dispensary.svg
- Updated All Brands icon to briefcase-in-circle design
- Files updated: sales accounts/dashboard, executive dashboard,
  manufacturing dashboard, business setup forms, buyer setup
2025-12-17 01:09:19 -07:00
kelly
983752a396 fix: use dispensary icon on sales accounts page, bookmark icon for brands
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Sales accounts page: use dispensary.svg instead of colored placeholder
- Brand switcher: use bookmark icon (matching sidebar Brands menu)
2025-12-17 01:05:29 -07:00
kelly
b338571fb9 style: match All Brands icon size to sidebar icons (size-4)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-17 01:00:56 -07:00
kelly
7990c2ab55 style: change All Brands icon to briefcase design
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Matches the same gray (#6b7280) as contacts icon
- Briefcase with handle and center clasp
2025-12-17 01:00:07 -07:00
kelly
e419c57072 style: lighten All Brands icon 15% and reduce size 10%
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Changed color from #6b7280 to #8b939e (15% lighter)
- Reduced button icon from size-6 to size-5
- Reduced dropdown icon from size-5 to size-4
2025-12-17 00:57:42 -07:00
kelly
05144fe04b feat: add folder-heart icon for All Brands in brand switcher
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Created all-brands.svg with folder + heart design
- Uses same gray color (#6b7280) as other default icons
- Transparent background for consistency
2025-12-17 00:56:21 -07:00
kelly
23aa144f85 fix: brand switcher - clicking 'All Brands' navigates to /brands
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- When no brand is selected, clicking 'All Brands' text navigates to brands index
- Chevron still opens dropdown to select a specific brand
- When a brand is selected, clicking the brand name opens dropdown
- Reverted to original grid icon style for 'All Brands'
2025-12-17 00:54:39 -07:00
kelly
bdf56d3d95 fix: show business name in brand switcher instead of 'All Brands'
Some checks failed
ci/woodpecker/push/ci Pipeline failed
When no brand is selected, show the business name (e.g., 'Cannabrands')
in the brand switcher button instead of 'All Brands' to avoid confusion
with the dropdown option.
2025-12-17 00:52:50 -07:00
kelly
de1574f920 fix: standardize default icons and rename Accounts to Customers
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Replace CDN URLs (cdn.spdy.io) with local /images/defaults/ paths
- Use dispensary.svg for accounts/stores (business entities)
- Use contact.svg for contacts and leads (people)
- Rename 'Accounts' to 'Customers' in sidebar and breadcrumbs
- Fix brand switcher to show Cannabrands logo when no brand selected
2025-12-17 00:48:49 -07:00
kelly
ff2f9ba64c fix: use default icons for contacts and accounts lists
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- contacts: use contact.svg icon
- accounts: use dispensary.svg icon
2025-12-17 00:41:15 -07:00
kelly
362cb8091b refactor: rename Accounts to Customers in sidebar menu
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-17 00:38:42 -07:00
kelly
0af81efac0 fix: use contact icon in contacts list view
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-17 00:37:35 -07:00
kelly
c705ef0cd0 feat: add contact icon for account avatars in sales dashboard
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Create contact.svg with same gray (#6b7280) as dispensary icon
- Use contact icon instead of dispensary for customer accounts
2025-12-17 00:35:14 -07:00
kelly
ae5d7bf47a fix: use default dispensary icon for account avatars in sales dashboard
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-17 00:30:20 -07:00
kelly
76ced26127 fix(k8s): use registry.spdy.io for base images
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Use registry.spdy.io/library/busybox for setup-storage init container
instead of Docker Hub to avoid rate limiting issues.

- Add setup-storage init container with busybox from local registry
- Registry is synced daily from Docker Hub via registry-sync-cronjob
2025-12-16 22:17:30 -07:00
kelly
e98ad5d611 chore: replace code.cannabrands.app with git.spdy.io
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Update all references to use the correct registry and git URLs:
- K8s deployment images: registry.spdy.io
- Documentation links: git.spdy.io
- CI/CD references: git.spdy.io

Also update CLAUDE.md:
- Mark PostgreSQL as external database (do not create on spdy.io)
- Update Docker Registry to registry.spdy.io (HTTPS)
2025-12-16 20:39:48 -07:00
kelly
37a6eb67b2 fix: resolve duplicate bom.update route name conflict
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
- Rename bom.update to bom.component.update for component updates
- Rename bom.detach to bom.component.detach for component removal
- Update view references to use new route names
- Fixes route:cache serialization error
2025-12-16 19:29:52 -07:00
kelly
2bf97fe4e1 config(k8s): use external PostgreSQL and Redis for dev
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
- DB_HOST: 10.100.6.50 (external PostgreSQL)
- REDIS_HOST: 10.100.9.50 (external Redis)
- Remove in-cluster postgres and redis StatefulSets
- Removes postgres-patch.yaml and redis-patch.yaml from patches
2025-12-16 19:07:58 -07:00
kelly
f9130d1c67 feat: add plus addressing for user-specific email routing
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
- Reply-To can include user ID: inbox+u123@domain.com
- InboundEmailService parses plus address to auto-assign threads
- When user starts conversation, replies route back to them
- Verifies user belongs to business before assigning
2025-12-16 18:02:29 -07:00
kelly
a542112361 feat: add Reply-To headers to all transactional emails
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
- Create HasBusinessReplyTo trait for consistent Reply-To handling
- Apply trait to QuoteMail, InvoiceSentMail, and all Order emails
- Reply-To uses business's primary email identity when configured
- Enables email replies to route back to CRM inbox
2025-12-16 17:53:20 -07:00
kelly
b2108651d8 feat: enable email replies to route to CRM inbox
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Add Reply-To header to QuoteMail using business's primary email identity
- Add emailIdentities() and primaryEmailIdentity() relationships to Business model
- Add notification to InboundEmailService for inbound email messages

When a user sends a quote (or invoice/order) email and the recipient
replies, the reply will:
1. Come in via webhook (Postmark, SendGrid, etc.)
2. Be processed by InboundEmailService
3. Threaded using In-Reply-To/References headers
4. Create a CrmChannelMessage in the CRM inbox
5. Send notification to assigned user or business users
2025-12-16 17:50:17 -07:00
kelly
b81b3ea8eb feat: remove chat widget from seller side, add message notifications
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Hide marketplace chat widget for sellers (only show for buyers)
- Sellers use CRM inbox instead of chat widget
- Create CrmNewMessageNotification for inbound messages
- Add 'message' style to NotificationStyleService
- Send notifications to assigned user or business users on new messages
2025-12-16 17:46:14 -07:00
kelly
3ae38ff5ee fix: resolve PDF generation and invoice location validation
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- QuoteController: Change pdf->inline() to pdf->stream() for DomPDF v3.x compatibility
- InvoiceController: Fix validation rule to use 'locations' table (not 'business_locations')

Fixes Issue #5 (PDF error) and Issue #8 (invoice submission error)
2025-12-16 17:45:55 -07:00
kelly
5218a44701 refactor: remove agent status from inbox, rely on user area
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Remove agent status dropdown from inbox top bar
- Remove presence channel subscription
- Remove agent status heartbeat
- Remove onlineUsers tracking
- Status is managed in user area settings instead
2025-12-16 17:43:52 -07:00
kelly
6234cea62c fix: remove character limit validation from product descriptions (Issue #7)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Long description fields (long_description, consumer_long_description,
buyer_long_description) no longer have max character limits, allowing
full product descriptions without truncation.
2025-12-16 17:39:48 -07:00
kelly
6e26a65f12 feat: add menu toggle button to inbox top bar
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Hamburger icon toggles sidebar expanded/collapsed state so users
can access navigation menu while in the inbox view.
2025-12-16 17:38:56 -07:00
kelly
86532e27fe fix: swap SMS and Chat icons
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- SMS: phone icon (device-phone-mobile)
- Chat: chat bubble icon (chat-bubble-left-right)
2025-12-16 17:37:22 -07:00
kelly
f2b8d04d03 feat: auto-collapse sidebar on inbox page for more space
User can hover to expand sidebar if needed.
2025-12-16 17:36:40 -07:00
kelly
615dbd3ee6 refactor: redesign unified inbox with Chatwoot-style layout
Some checks failed
ci/woodpecker/push/ci Pipeline failed
- Add top bar with channel tabs (All, Email, SMS, WhatsApp, Instagram, Chat)
- Move status filters (All, Open, Pending, Closed) to top bar right side
- Move agent status dropdown to top bar
- Inline thread list with search and assignment filter
- Clean 3-column layout: thread list | messages | context sidebar
- Remove separate agent-status-widget and thread-filters partials
- Add null checks for Echo to prevent errors if WebSockets unavailable
2025-12-16 17:34:12 -07:00
kelly
fb6cf407d4 fix: make contacts.last_name nullable (Issue #9)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
The controller validation allows null last_name but the database
column was NOT NULL, causing insert failures.
2025-12-16 17:29:00 -07:00
kelly
9fcfc8b484 fix: correct users table column names in unified inbox query
Some checks failed
ci/woodpecker/push/ci Pipeline failed
The users table has first_name/last_name columns, not a name column.
Map the query results to include a computed 'name' field for view
compatibility since the Blade templates expect member.name.
2025-12-16 17:27:11 -07:00
kelly
829d4c6b6c fix: correct users table column names in unified inbox query
The users table has first_name/last_name columns, not a name column.
The User model has a name accessor that concatenates these, but SQL
queries must reference the actual column names.
2025-12-16 17:26:27 -07:00
kelly
60e1564c0f Merge branch 'feat/omnichannel-inbox' into develop
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2025-12-16 16:40:06 -07:00
kelly
a42d1bc3c8 fix(ci): remove --parallel flag (paratest not installed)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2025-12-16 16:19:43 -07:00
kelly
729234bd7f fix(ci): install dev dependencies for lint/test steps
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-12-16 16:18:23 -07:00
kelly
1e7e1b5934 feat: add omnichannel unified inbox for sales reps
Some checks failed
ci/woodpecker/pr/ci Pipeline failed
Implements a Chatwoot-style unified inbox with real-time messaging
for sales reps to manage communications across all channels.

Features:
- Real-time messaging via Laravel Reverb WebSockets
- Sales rep filtering ("My Accounts Only") for assigned accounts
- Typing indicators showing when others are composing
- Agent status (online/away/busy) with heartbeat monitoring
- Email engagement sidebar (opens/clicks from marketing)
- Quick replies with variable substitution
- Presence awareness (who's online in team)
- Three-column layout: thread list, conversation, context sidebar

New files:
- Broadcasting events for real-time updates
- Unified inbox view with Alpine.js component
- 9 Blade partials for modular UI

Access via: Inbox → Conversations in sidebar
Route: /s/{business}/crm/inbox
2025-12-16 15:40:59 -07:00
kelly
db2386b8a9 feat: add Nuvata products to missing_products.php
Added 8 Nuvata products (NU-*) to the data file so they get created
on production without needing MySQL connection.
2025-12-12 09:14:52 -07:00
189 changed files with 12341 additions and 1583 deletions

View File

@@ -38,13 +38,17 @@ steps:
- |
cat > .env << 'EOF'
APP_NAME="Cannabrands Hub"
APP_ENV=production
APP_ENV=development
APP_KEY=base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
EOF
# Restore composer cache if available
- mkdir -p /root/.composer/cache
- if [ -d .composer-cache ]; then cp -r .composer-cache/* /root/.composer/cache/ 2>/dev/null || true; fi
- composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader --no-progress
# Clean vendor and bootstrap cache to force fresh install
- rm -rf vendor bootstrap/cache/*.php
- composer install --no-interaction --prefer-dist --optimize-autoloader --no-progress
# Verify test command is available
- php artisan list test | head -5
# Save cache for next build
- mkdir -p .composer-cache && cp -r /root/.composer/cache/* .composer-cache/ 2>/dev/null || true
- echo "✅ Composer done"
@@ -85,28 +89,8 @@ steps:
when:
event: pull_request
# Split tests: Unit tests (fast, no DB)
# Split tests: Unit tests (with DB - some unit tests use factories)
tests-unit:
image: 10.100.9.70:5000/kirschbaumdevelopment/laravel-test-runner:8.3
depends_on:
- composer-install
when:
event: pull_request
environment:
APP_ENV: testing
CACHE_STORE: array
SESSION_DRIVER: array
QUEUE_CONNECTION: sync
DB_CONNECTION: sqlite
DB_DATABASE: ":memory:"
commands:
- cp .env.example .env
- php artisan key:generate
- php artisan test --testsuite=Unit --parallel
- echo "✅ Unit tests passed"
# Split tests: Feature tests (with DB)
tests-feature:
image: 10.100.9.70:5000/kirschbaumdevelopment/laravel-test-runner:8.3
depends_on:
- composer-install
@@ -123,13 +107,37 @@ steps:
DB_DATABASE: cannabrands_test
DB_USERNAME: cannabrands
DB_PASSWORD: SpDyCannaBrands2024
commands:
- cp .env.example .env
- php artisan key:generate
- php artisan test --testsuite=Unit
- echo "✅ Unit tests passed"
# Split tests: Feature tests (with DB)
tests-feature:
image: 10.100.9.70:5000/kirschbaumdevelopment/laravel-test-runner:8.3
depends_on:
- composer-install
when:
event: pull_request
environment:
APP_ENV: testing
CACHE_STORE: array
SESSION_DRIVER: array
QUEUE_CONNECTION: sync
DB_CONNECTION: pgsql
DB_HOST: 10.100.7.50
DB_PORT: 5432
DB_DATABASE: cannabrands_test
DB_USERNAME: cannabrands
DB_PASSWORD: SpDyCannaBrands2024
REDIS_HOST: 10.100.9.50
REDIS_PORT: 6379
REDIS_PASSWORD: SpDyR3d1s2024!
commands:
- cp .env.example .env
- php artisan key:generate
- php artisan test --testsuite=Feature --parallel
- php artisan test --testsuite=Feature
- echo "✅ Feature tests passed"
# ============================================

View File

@@ -69,14 +69,14 @@ git push origin develop
**Before (Mutable Tags - Problematic):**
```
code.cannabrands.app/cannabrands/hub:dev # Overwritten each build
git.spdy.io/cannabrands/hub:dev # Overwritten each build
```
**After (Immutable Tags - Best Practice):**
```
code.cannabrands.app/cannabrands/hub:dev-a28d5b5 # Unique SHA tag
code.cannabrands.app/cannabrands/hub:dev # Latest dev (convenience)
code.cannabrands.app/cannabrands/hub:sha-a28d5b5 # Generic SHA
git.spdy.io/cannabrands/hub:dev-a28d5b5 # Unique SHA tag
git.spdy.io/cannabrands/hub:dev # Latest dev (convenience)
git.spdy.io/cannabrands/hub:sha-a28d5b5 # Generic SHA
```
### Auto-Deploy Flow
@@ -109,14 +109,14 @@ If a deployment breaks dev, roll back to the previous version:
kubectl get deployment cannabrands-hub -n cannabrands-dev \
-o jsonpath='{.spec.template.spec.containers[0].image}'
# Output: code.cannabrands.app/cannabrands/hub:dev-a28d5b5
# Output: git.spdy.io/cannabrands/hub:dev-a28d5b5
# 2. Check git log for previous commit
git log --oneline develop | head -5
# 3. Rollback to previous SHA
kubectl set image deployment/cannabrands-hub \
app=code.cannabrands.app/cannabrands/hub:dev-PREVIOUS_SHA \
app=git.spdy.io/cannabrands/hub:dev-PREVIOUS_SHA \
-n cannabrands-dev
# 4. Verify rollback
@@ -156,7 +156,7 @@ deploy-staging:
- chmod 600 ~/.kube/config
- |
kubectl set image deployment/cannabrands-hub \
app=code.cannabrands.app/cannabrands/hub:staging-${CI_COMMIT_SHA:0:7} \
app=git.spdy.io/cannabrands/hub:staging-${CI_COMMIT_SHA:0:7} \
-n cannabrands-staging
- kubectl rollout status deployment/cannabrands-hub -n cannabrands-staging --timeout=300s
when:
@@ -207,7 +207,7 @@ kubectl logs -n cannabrands-dev deployment/cannabrands-hub --tail=100
cannabrands-hub-7d85986845-gnkbv 1/1 Running 0 45s
Image deployed:
code.cannabrands.app/cannabrands/hub:dev-a28d5b5
git.spdy.io/cannabrands/hub:dev-a28d5b5
```
---

View File

@@ -47,8 +47,8 @@ steps:
build-image:
image: plugins/docker
settings:
registry: code.cannabrands.app
repo: code.cannabrands.app/cannabrands/hub
registry: git.spdy.io
repo: git.spdy.io/cannabrands/hub
tags: [latest, ${CI_COMMIT_SHA:0:8}]
when:
branch: master
@@ -68,7 +68,7 @@ steps:
```bash
# On production server
ssh cannabrands-prod
docker pull code.cannabrands.app/cannabrands/hub:bef77df8
docker pull git.spdy.io/cannabrands/hub:bef77df8
docker-compose up -d
# Or use deployment tool like Ansible, Deployer, etc.
```
@@ -108,7 +108,7 @@ steps:
from_secret: ssh_private_key
script:
- cd /var/www/cannabrands
- docker pull code.cannabrands.app/cannabrands/hub:${CI_COMMIT_SHA:0:8}
- docker pull git.spdy.io/cannabrands/hub:${CI_COMMIT_SHA:0:8}
- docker-compose up -d
- docker exec cannabrands php artisan migrate --force
- docker exec cannabrands php artisan config:cache
@@ -160,7 +160,7 @@ steps:
from_secret: ssh_private_key
script:
- cd /var/www/cannabrands
- docker pull code.cannabrands.app/cannabrands/hub:${CI_COMMIT_SHA:0:8}
- docker pull git.spdy.io/cannabrands/hub:${CI_COMMIT_SHA:0:8}
- docker-compose up -d
when:
branch: develop
@@ -176,7 +176,7 @@ steps:
from_secret: ssh_private_key
script:
- cd /var/www/cannabrands
- docker pull code.cannabrands.app/cannabrands/hub:${CI_COMMIT_SHA:0:8}
- docker pull git.spdy.io/cannabrands/hub:${CI_COMMIT_SHA:0:8}
- docker-compose up -d
when:
branch: master
@@ -367,7 +367,7 @@ Production:
```bash
# Quick rollback (under 2 minutes)
ssh cannabrands-prod
docker pull code.cannabrands.app/cannabrands/hub:PREVIOUS_COMMIT_SHA
docker pull git.spdy.io/cannabrands/hub:PREVIOUS_COMMIT_SHA
docker-compose up -d
# Database rollback (if migrations ran)
@@ -536,8 +536,8 @@ steps:
build-image:
image: plugins/docker
settings:
registry: code.cannabrands.app
repo: code.cannabrands.app/cannabrands/hub
registry: git.spdy.io
repo: git.spdy.io/cannabrands/hub
tags:
- ${CI_COMMIT_BRANCH}
- ${CI_COMMIT_SHA:0:8}
@@ -559,7 +559,7 @@ steps:
from_secret: staging_ssh_key
script:
- cd /var/www/cannabrands
- docker pull code.cannabrands.app/cannabrands/hub:${CI_COMMIT_SHA:0:8}
- docker pull git.spdy.io/cannabrands/hub:${CI_COMMIT_SHA:0:8}
- docker-compose up -d
- docker exec cannabrands php artisan migrate --force
- docker exec cannabrands php artisan config:cache
@@ -582,7 +582,7 @@ steps:
- echo "To deploy to production:"
- echo " ssh cannabrands-prod"
- echo " cd /var/www/cannabrands"
- echo " docker pull code.cannabrands.app/cannabrands/hub:${CI_COMMIT_SHA:0:8}"
- echo " docker pull git.spdy.io/cannabrands/hub:${CI_COMMIT_SHA:0:8}"
- echo " docker-compose up -d"
- echo ""
- echo "⚠️ Remember: Check deployment checklist first!"

View File

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

View File

@@ -254,25 +254,25 @@ WORKDIR /woodpecker/src
**Build and push to Gitea:**
```bash
docker build -f docker/ci-php.Dockerfile -t code.cannabrands.app/cannabrands/ci-php:8.3 .
docker push code.cannabrands.app/cannabrands/ci-php:8.3
docker build -f docker/ci-php.Dockerfile -t git.spdy.io/cannabrands/ci-php:8.3 .
docker push git.spdy.io/cannabrands/ci-php:8.3
```
**Update `.woodpecker/.ci.yml`:**
```yaml
steps:
php-lint:
image: code.cannabrands.app/cannabrands/ci-php:8.3
image: git.spdy.io/cannabrands/ci-php:8.3
commands:
- find app routes database -name "*.php" -exec php -l {} \;
composer-install:
image: code.cannabrands.app/cannabrands/ci-php:8.3
image: git.spdy.io/cannabrands/ci-php:8.3
commands:
- composer install --no-interaction --prefer-dist --optimize-autoloader
code-style:
image: code.cannabrands.app/cannabrands/ci-php:8.3
image: git.spdy.io/cannabrands/ci-php:8.3
commands:
- ./vendor/bin/pint --test
```

View File

@@ -107,7 +107,7 @@ version: '3.8'
services:
app:
image: code.cannabrands.app/cannabrands/hub:latest
image: git.spdy.io/cannabrands/hub:latest
container_name: cannabrands_app
restart: unless-stopped
ports:
@@ -204,8 +204,8 @@ steps:
build-image:
image: woodpeckerci/plugin-docker-buildx
settings:
registry: code.cannabrands.app
repo: code.cannabrands.app/cannabrands/hub
registry: git.spdy.io
repo: git.spdy.io/cannabrands/hub
username:
from_secret: gitea_username
password:
@@ -564,7 +564,7 @@ docker images | grep cannabrands
```bash
# Pull previous commit's image
docker pull code.cannabrands.app/cannabrands/hub:PREVIOUS_SHA
docker pull git.spdy.io/cannabrands/hub:PREVIOUS_SHA
# Update docker-compose.yml to use specific tag
docker compose up -d app

View File

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

View File

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

View File

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

View File

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

View File

@@ -77,11 +77,13 @@ curl -X POST "https://git.spdy.io/api/v1/repos/Cannabrands/hub/pulls" \
|---------|------|-------|
| **Gitea** | `https://git.spdy.io` | Git repository |
| **Woodpecker CI** | `https://ci.spdy.io` | CI/CD pipelines |
| **Docker Registry** | `10.100.9.70:5000` | Local registry (insecure) |
| **Docker Registry** | `registry.spdy.io` | HTTPS registry with Let's Encrypt |
**PostgreSQL (Dev)**
**PostgreSQL (Dev) - EXTERNAL DATABASE**
⚠️ **DO NOT create PostgreSQL databases on spdy.io infrastructure for cannabrands.**
Cannabrands uses an external managed PostgreSQL database.
```
Host: 10.100.6.50
Host: 10.100.6.50 (read replica)
Port: 5432
Database: cannabrands_dev
Username: cannabrands
@@ -128,8 +130,8 @@ Woodpecker secrets: `registry_user`, `registry_password`
**CI/CD Notes:**
- Uses **Kaniko** for Docker builds (no Docker daemon, avoids DNS issues)
- Images pushed to `git.spdy.io/cannabrands/hub` (k8s can pull without insecure config)
- Base images pulled from local registry `10.100.9.70:5000` (Kaniko handles insecure)
- Images pushed to `registry.spdy.io/cannabrands/hub`
- Base images pulled from `registry.spdy.io` (HTTPS with Let's Encrypt)
- Deploy: `develop` → dev.cannabrands.app, `master` → cannabrands.app
### 8. User-Business Relationship (Pivot Table)

View File

@@ -44,7 +44,7 @@ Our workflow provides audit trails regulators love:
1. **Clone the repository**
```bash
git clone https://code.cannabrands.app/Cannabrands/hub.git
git clone https://git.spdy.io/Cannabrands/hub.git
cd hub
```
@@ -86,7 +86,7 @@ git commit -m "feat: add new feature"
git push origin feature/my-feature-name
# 4. Create Pull Request on Gitea
# - Navigate to https://code.cannabrands.app
# - Navigate to https://git.spdy.io
# - Create PR to merge your branch into develop
# - CI will run automatically
# - Request review from team
@@ -630,7 +630,7 @@ git push origin chore/changelog-2025.11.1
### Services
- **Woodpecker CI:** `https://ci.cannabrands.app`
- **Gitea:** `https://code.cannabrands.app`
- **Gitea:** `https://git.spdy.io`
- **Production:** `https://app.cannabrands.com` (future)
---

View File

@@ -0,0 +1,144 @@
<?php
namespace App\Console\Commands;
use App\Models\Business;
use App\Models\BusinessDba;
use Illuminate\Console\Command;
/**
* Migrate existing business DBA data to the new business_dbas table.
*
* This command creates DBA records from existing business fields:
* - dba_name
* - invoice_payable_company_name, invoice_payable_address, etc.
* - ap_contact_* fields
* - primary_contact_* fields
*/
class MigrateDbaData extends Command
{
protected $signature = 'dba:migrate
{--dry-run : Show what would be created without actually creating records}
{--business= : Migrate only a specific business by ID or slug}
{--force : Skip confirmation prompt}';
protected $description = 'Migrate existing dba_name and invoice_payable_* fields to the business_dbas table';
public function handle(): int
{
$this->info('DBA Data Migration');
$this->line('==================');
$dryRun = $this->option('dry-run');
$specificBusiness = $this->option('business');
if ($dryRun) {
$this->warn('DRY RUN MODE - No records will be created');
}
// Build query
$query = Business::query()
->whereNotNull('dba_name')
->where('dba_name', '!=', '');
if ($specificBusiness) {
$query->where(function ($q) use ($specificBusiness) {
$q->where('id', $specificBusiness)
->orWhere('slug', $specificBusiness);
});
}
$businesses = $query->get();
$this->info("Found {$businesses->count()} businesses with dba_name set.");
if ($businesses->isEmpty()) {
$this->info('No businesses to migrate.');
return self::SUCCESS;
}
// Show preview
$this->newLine();
$this->table(
['ID', 'Business Name', 'DBA Name', 'Has Invoice Address', 'Already Has DBAs'],
$businesses->map(fn ($b) => [
$b->id,
\Illuminate\Support\Str::limit($b->name, 30),
\Illuminate\Support\Str::limit($b->dba_name, 30),
$b->invoice_payable_address ? 'Yes' : 'No',
$b->dbas()->exists() ? 'Yes' : 'No',
])
);
if (! $dryRun && ! $this->option('force')) {
if (! $this->confirm('Do you want to proceed with creating DBA records?')) {
$this->info('Aborted.');
return self::SUCCESS;
}
}
$created = 0;
$skipped = 0;
foreach ($businesses as $business) {
// Skip if business already has DBAs
if ($business->dbas()->exists()) {
$this->line(" Skipping {$business->name} - already has DBAs");
$skipped++;
continue;
}
if ($dryRun) {
$this->line(" Would create DBA for: {$business->name} -> {$business->dba_name}");
$created++;
continue;
}
// Create DBA from existing business fields
$dba = BusinessDba::create([
'business_id' => $business->id,
'trade_name' => $business->dba_name,
// Address - prefer invoice_payable fields, fall back to physical
'address' => $business->invoice_payable_address ?: $business->physical_address,
'city' => $business->invoice_payable_city ?: $business->physical_city,
'state' => $business->invoice_payable_state ?: $business->physical_state,
'zip' => $business->invoice_payable_zipcode ?: $business->physical_zipcode,
// License
'license_number' => $business->license_number,
'license_type' => $business->license_type,
// Contacts
'primary_contact_name' => trim(($business->primary_contact_first_name ?? '').' '.($business->primary_contact_last_name ?? '')) ?: null,
'primary_contact_email' => $business->primary_contact_email,
'primary_contact_phone' => $business->primary_contact_phone,
'ap_contact_name' => trim(($business->ap_contact_first_name ?? '').' '.($business->ap_contact_last_name ?? '')) ?: null,
'ap_contact_email' => $business->ap_contact_email,
'ap_contact_phone' => $business->ap_contact_phone,
// Invoice Settings
'invoice_footer' => $business->order_invoice_footer,
// Status
'is_default' => true,
'is_active' => true,
]);
$this->info(" Created DBA #{$dba->id} for {$business->name}: {$dba->trade_name}");
$created++;
}
$this->newLine();
$this->info("Summary: {$created} created, {$skipped} skipped");
if ($dryRun) {
$this->warn('Run without --dry-run to actually create records.');
}
return self::SUCCESS;
}
}

View File

@@ -131,6 +131,20 @@ class Kernel extends ConsoleKernel
->withoutOverlapping()
->runInBackground();
// ─────────────────────────────────────────────────────────────────────
// BANNER ADS
// ─────────────────────────────────────────────────────────────────────
// Update banner ad statuses (activate scheduled, expire ended) - every minute
$schedule->job(new \App\Jobs\UpdateBannerAdStatuses)
->everyMinute()
->withoutOverlapping();
// Rollup daily banner ad stats - daily at 2 AM
$schedule->job(new \App\Jobs\RollupBannerAdStats)
->dailyAt('02:00')
->withoutOverlapping();
// ─────────────────────────────────────────────────────────────────────
// HOUSEKEEPING & MAINTENANCE
// ─────────────────────────────────────────────────────────────────────

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Enums;
enum BannerAdStatus: string
{
case DRAFT = 'draft';
case ACTIVE = 'active';
case SCHEDULED = 'scheduled';
case PAUSED = 'paused';
case EXPIRED = 'expired';
public function label(): string
{
return match ($this) {
self::DRAFT => 'Draft',
self::ACTIVE => 'Active',
self::SCHEDULED => 'Scheduled',
self::PAUSED => 'Paused',
self::EXPIRED => 'Expired',
};
}
public function color(): string
{
return match ($this) {
self::DRAFT => 'gray',
self::ACTIVE => 'success',
self::SCHEDULED => 'info',
self::PAUSED => 'warning',
self::EXPIRED => 'danger',
};
}
public static function options(): array
{
return collect(self::cases())->mapWithKeys(fn (self $status) => [
$status->value => $status->label(),
])->toArray();
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Enums;
enum BannerAdZone: string
{
case MARKETPLACE_HERO = 'marketplace_hero';
case MARKETPLACE_LEADERBOARD = 'marketplace_leaderboard';
case MARKETPLACE_SIDEBAR = 'marketplace_sidebar';
case MARKETPLACE_INLINE = 'marketplace_inline';
case BRAND_PAGE_BANNER = 'brand_page_banner';
case DEALS_PAGE_HERO = 'deals_page_hero';
public function label(): string
{
return match ($this) {
self::MARKETPLACE_HERO => 'Marketplace Hero (Full Width)',
self::MARKETPLACE_LEADERBOARD => 'Marketplace Leaderboard (728x90)',
self::MARKETPLACE_SIDEBAR => 'Marketplace Sidebar (300x250)',
self::MARKETPLACE_INLINE => 'Marketplace Inline (Between Products)',
self::BRAND_PAGE_BANNER => 'Brand Page Banner',
self::DEALS_PAGE_HERO => 'Deals Page Hero',
};
}
public function dimensions(): array
{
return match ($this) {
self::MARKETPLACE_HERO => ['width' => 1920, 'height' => 400, 'display' => '1920x400'],
self::MARKETPLACE_LEADERBOARD => ['width' => 728, 'height' => 90, 'display' => '728x90'],
self::MARKETPLACE_SIDEBAR => ['width' => 300, 'height' => 250, 'display' => '300x250'],
self::MARKETPLACE_INLINE => ['width' => 970, 'height' => 250, 'display' => '970x250'],
self::BRAND_PAGE_BANNER => ['width' => 1344, 'height' => 280, 'display' => '1344x280'],
self::DEALS_PAGE_HERO => ['width' => 1920, 'height' => 350, 'display' => '1920x350'],
};
}
public static function options(): array
{
return collect(self::cases())->mapWithKeys(fn (self $zone) => [
$zone->value => $zone->label().' - '.$zone->dimensions()['display'],
])->toArray();
}
public static function optionsSimple(): array
{
return collect(self::cases())->mapWithKeys(fn (self $zone) => [
$zone->value => $zone->label(),
])->toArray();
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Events;
use App\Models\AgentStatus;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class CrmAgentStatusChanged implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public AgentStatus $agentStatus
) {}
public function broadcastOn(): array
{
return [new PrivateChannel("crm-inbox.{$this->agentStatus->business_id}")];
}
public function broadcastAs(): string
{
return 'agent.status';
}
public function broadcastWith(): array
{
return [
'user_id' => $this->agentStatus->user_id,
'user_name' => $this->agentStatus->user?->name,
'status' => $this->agentStatus->status,
'status_label' => AgentStatus::statuses()[$this->agentStatus->status] ?? $this->agentStatus->status,
'status_message' => $this->agentStatus->status_message,
'last_seen_at' => $this->agentStatus->last_seen_at?->toIso8601String(),
];
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace App\Events;
use App\Models\Crm\CrmChannelMessage;
use App\Models\Crm\CrmThread;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Storage;
class CrmThreadMessageSent implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public CrmChannelMessage $message,
public CrmThread $thread
) {}
public function broadcastOn(): array
{
$channels = [
new PrivateChannel("crm-inbox.{$this->thread->business_id}"),
new PrivateChannel("crm-thread.{$this->thread->id}"),
];
// For marketplace B2B threads, also broadcast to buyer/seller businesses
if ($this->thread->buyer_business_id) {
$channels[] = new PrivateChannel("crm-inbox.{$this->thread->buyer_business_id}");
}
if ($this->thread->seller_business_id) {
$channels[] = new PrivateChannel("crm-inbox.{$this->thread->seller_business_id}");
}
return $channels;
}
public function broadcastAs(): string
{
return 'message.new';
}
public function broadcastWith(): array
{
return [
'message' => [
'id' => $this->message->id,
'thread_id' => $this->message->thread_id,
'body' => $this->message->body,
'body_html' => $this->message->body_html,
'direction' => $this->message->direction,
'channel_type' => $this->message->channel_type,
'sender_id' => $this->message->user_id,
'sender_name' => $this->message->user?->name ?? ($this->message->direction === 'inbound' ? $this->thread->contact?->getFullName() : 'System'),
'status' => $this->message->status,
'created_at' => $this->message->created_at->toIso8601String(),
'attachments' => $this->message->attachments->map(fn ($a) => [
'id' => $a->id,
'filename' => $a->original_filename ?? $a->filename,
'mime_type' => $a->mime_type,
'size' => $a->size,
'url' => Storage::disk($a->disk ?? 'minio')->url($a->path),
])->toArray(),
],
'thread' => [
'id' => $this->thread->id,
'subject' => $this->thread->subject,
'status' => $this->thread->status,
'priority' => $this->thread->priority,
'last_message_at' => $this->thread->last_message_at?->toIso8601String(),
'last_message_preview' => $this->message->body ? \Str::limit(strip_tags($this->message->body), 100) : null,
'last_message_direction' => $this->message->direction,
'last_channel_type' => $this->message->channel_type,
],
];
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Events;
use App\Models\Crm\CrmThread;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class CrmThreadUpdated implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public const UPDATE_ASSIGNED = 'assigned';
public const UPDATE_CLOSED = 'closed';
public const UPDATE_REOPENED = 'reopened';
public const UPDATE_SNOOZED = 'snoozed';
public const UPDATE_PRIORITY = 'priority';
public const UPDATE_STATUS = 'status';
public function __construct(
public CrmThread $thread,
public string $updateType
) {}
public function broadcastOn(): array
{
$channels = [
new PrivateChannel("crm-inbox.{$this->thread->business_id}"),
new PrivateChannel("crm-thread.{$this->thread->id}"),
];
// For marketplace B2B threads, also broadcast to buyer/seller businesses
if ($this->thread->buyer_business_id) {
$channels[] = new PrivateChannel("crm-inbox.{$this->thread->buyer_business_id}");
}
if ($this->thread->seller_business_id) {
$channels[] = new PrivateChannel("crm-inbox.{$this->thread->seller_business_id}");
}
return $channels;
}
public function broadcastAs(): string
{
return 'thread.updated';
}
public function broadcastWith(): array
{
return [
'thread' => [
'id' => $this->thread->id,
'subject' => $this->thread->subject,
'status' => $this->thread->status,
'priority' => $this->thread->priority,
'assigned_to' => $this->thread->assigned_to,
'assignee_name' => $this->thread->assignee?->name,
'snoozed_until' => $this->thread->snoozed_until?->toIso8601String(),
'last_message_at' => $this->thread->last_message_at?->toIso8601String(),
],
'update_type' => $this->updateType,
'updated_at' => now()->toIso8601String(),
];
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class CrmTypingIndicator implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public int $threadId,
public int $userId,
public string $userName,
public bool $isTyping
) {}
public function broadcastOn(): array
{
return [new PrivateChannel("crm-thread.{$this->threadId}")];
}
public function broadcastAs(): string
{
return 'typing';
}
public function broadcastWith(): array
{
return [
'user_id' => $this->userId,
'user_name' => $this->userName,
'is_typing' => $this->isTyping,
'timestamp' => now()->toIso8601String(),
];
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Events;
use App\Models\TeamMessage;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class TeamMessageSent implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public TeamMessage $message
) {}
public function broadcastOn(): array
{
// Broadcast to the team conversation channel
return [
new PrivateChannel('team-conversation.'.$this->message->conversation_id),
];
}
public function broadcastAs(): string
{
return 'message.sent';
}
public function broadcastWith(): array
{
return [
'id' => $this->message->id,
'conversation_id' => $this->message->conversation_id,
'sender_id' => $this->message->sender_id,
'sender_name' => $this->message->getSenderName(),
'sender_initials' => $this->message->getSenderInitials(),
'body' => $this->message->body,
'type' => $this->message->type,
'metadata' => $this->message->metadata,
'created_at' => $this->message->created_at->toIso8601String(),
];
}
}

View File

@@ -0,0 +1,292 @@
<?php
namespace App\Filament\Resources;
use App\Enums\BannerAdStatus;
use App\Enums\BannerAdZone;
use App\Filament\Resources\BannerAdResource\Pages;
use App\Models\BannerAd;
use BackedEnum;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use UnitEnum;
class BannerAdResource extends Resource
{
protected static ?string $model = BannerAd::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedPhoto;
protected static UnitEnum|string|null $navigationGroup = 'Marketing';
protected static ?int $navigationSort = 10;
protected static ?string $navigationLabel = 'Banner Ads';
public static function getNavigationBadge(): ?string
{
return cache()->remember('banner_ad_active_count', 60, function () {
$count = static::getModel()::where('status', BannerAdStatus::ACTIVE)->count();
return $count ?: null;
});
}
public static function form(Schema $schema): Schema
{
return $schema
->components([
Section::make('Basic Information')
->columns(2)
->schema([
TextInput::make('name')
->label('Internal Name')
->required()
->maxLength(255)
->helperText('Internal reference name (not shown to users)'),
Select::make('zone')
->label('Ad Zone')
->options(BannerAdZone::options())
->required()
->live()
->afterStateUpdated(fn ($state, $set) => $state
? $set('zone_info', BannerAdZone::from($state)->dimensions()['display'])
: $set('zone_info', null)),
Placeholder::make('zone_info')
->label('Recommended Dimensions')
->content(fn ($get) => $get('zone')
? BannerAdZone::from($get('zone'))->dimensions()['display']
: 'Select a zone'),
Select::make('status')
->options(BannerAdStatus::options())
->default('draft')
->required(),
Select::make('brand_id')
->label('Brand (Optional)')
->relationship('brand', 'name')
->searchable()
->preload()
->helperText('Leave empty for platform-wide ads'),
]),
Section::make('Creative Content')
->columns(2)
->schema([
FileUpload::make('image_path')
->label('Banner Image')
->image()
->required()
->disk('minio')
->directory('banner-ads')
->visibility('public')
->maxSize(5120)
->helperText('Upload banner image at recommended dimensions')
->columnSpanFull(),
TextInput::make('image_alt')
->label('Alt Text')
->maxLength(255)
->helperText('Accessibility description'),
TextInput::make('headline')
->maxLength(100)
->helperText('Optional overlay headline'),
Textarea::make('description')
->maxLength(200)
->helperText('Optional overlay description'),
TextInput::make('cta_text')
->label('Button Text')
->maxLength(50)
->placeholder('Shop Now')
->helperText('Call-to-action button text'),
TextInput::make('cta_url')
->label('Destination URL')
->required()
->url()
->maxLength(500)
->columnSpanFull(),
]),
Section::make('Scheduling')
->columns(2)
->schema([
DateTimePicker::make('starts_at')
->label('Start Date')
->helperText('Leave empty to start immediately'),
DateTimePicker::make('ends_at')
->label('End Date')
->helperText('Leave empty to run indefinitely'),
]),
Section::make('Targeting & Priority')
->columns(2)
->schema([
Toggle::make('is_platform_wide')
->label('Platform Wide')
->default(true)
->helperText('Show to all users'),
Select::make('target_business_types')
->label('Target Business Types')
->multiple()
->options([
'buyer' => 'Buyers (Dispensaries)',
'seller' => 'Sellers (Brands)',
])
->helperText('Leave empty for all types'),
TextInput::make('priority')
->numeric()
->default(0)
->helperText('Higher = shown first (0-100)'),
TextInput::make('weight')
->numeric()
->default(100)
->minValue(1)
->maxValue(1000)
->helperText('Weight for random rotation (1-1000)'),
]),
Section::make('Analytics')
->columns(3)
->schema([
Placeholder::make('impressions_display')
->label('Impressions')
->content(fn (?BannerAd $record) => number_format($record?->impressions ?? 0)),
Placeholder::make('clicks_display')
->label('Clicks')
->content(fn (?BannerAd $record) => number_format($record?->clicks ?? 0)),
Placeholder::make('ctr_display')
->label('CTR')
->content(fn (?BannerAd $record) => ($record?->click_through_rate ?? 0).'%'),
])
->hiddenOn('create'),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\ImageColumn::make('image_path')
->label('Preview')
->disk('minio')
->width(120)
->height(60),
Tables\Columns\TextColumn::make('name')
->searchable()
->sortable()
->weight('bold'),
Tables\Columns\TextColumn::make('zone')
->badge()
->formatStateUsing(fn ($state) => $state instanceof BannerAdZone
? $state->label()
: BannerAdZone::tryFrom($state)?->label() ?? $state),
Tables\Columns\TextColumn::make('status')
->badge()
->color(fn ($state) => $state instanceof BannerAdStatus
? $state->color()
: BannerAdStatus::tryFrom($state)?->color() ?? 'gray'),
Tables\Columns\TextColumn::make('impressions')
->numeric()
->sortable()
->toggleable(),
Tables\Columns\TextColumn::make('clicks')
->numeric()
->sortable()
->toggleable(),
Tables\Columns\TextColumn::make('click_through_rate')
->label('CTR')
->suffix('%')
->sortable()
->toggleable(),
Tables\Columns\TextColumn::make('starts_at')
->dateTime('M j, Y')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('ends_at')
->dateTime('M j, Y')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('created_at', 'desc')
->filters([
Tables\Filters\SelectFilter::make('status')
->options(BannerAdStatus::options()),
Tables\Filters\SelectFilter::make('zone')
->options(BannerAdZone::optionsSimple()),
Tables\Filters\TrashedFilter::make(),
])
->actions([
Tables\Actions\ViewAction::make(),
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
Tables\Actions\ForceDeleteBulkAction::make(),
Tables\Actions\RestoreBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [];
}
public static function getPages(): array
{
return [
'index' => Pages\ListBannerAds::route('/'),
'create' => Pages\CreateBannerAd::route('/create'),
'view' => Pages\ViewBannerAd::route('/{record}'),
'edit' => Pages\EditBannerAd::route('/{record}/edit'),
];
}
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->withoutGlobalScopes([
SoftDeletingScope::class,
]);
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Filament\Resources\BannerAdResource\Pages;
use App\Filament\Resources\BannerAdResource;
use Filament\Resources\Pages\CreateRecord;
class CreateBannerAd extends CreateRecord
{
protected static string $resource = BannerAdResource::class;
protected function mutateFormDataBeforeCreate(array $data): array
{
$data['created_by_user_id'] = auth()->id();
return $data;
}
protected function getRedirectUrl(): string
{
return $this->getResource()::getUrl('index');
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Filament\Resources\BannerAdResource\Pages;
use App\Filament\Resources\BannerAdResource;
use App\Services\BannerAdService;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditBannerAd extends EditRecord
{
protected static string $resource = BannerAdResource::class;
protected function getHeaderActions(): array
{
return [
Actions\ViewAction::make(),
Actions\DeleteAction::make(),
Actions\ForceDeleteAction::make(),
Actions\RestoreAction::make(),
];
}
protected function afterSave(): void
{
// Clear caches when banner ad is updated
app(BannerAdService::class)->clearAllCaches();
}
}

View File

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

View File

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

View File

@@ -699,6 +699,11 @@ class BusinessResource extends Resource
'</div>'
);
}),
Toggle::make('ping_pong_enabled')
->label('Ping Pong Order Flow')
->helperText('When enabled, buyers and sellers can send order details back and forth during the order process. Shows order progress stages and enables collaborative order editing.')
->default(false),
]),
// ===== SUITE ASSIGNMENT SECTION =====
@@ -2082,6 +2087,7 @@ class BusinessResource extends Resource
public static function getRelations(): array
{
return [
BusinessResource\RelationManagers\DbasRelationManager::class,
\Tapp\FilamentAuditing\RelationManagers\AuditsRelationManager::class,
];
}

View File

@@ -0,0 +1,235 @@
<?php
namespace App\Filament\Resources\BusinessResource\RelationManagers;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Grid;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Actions\CreateAction;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class DbasRelationManager extends RelationManager
{
protected static string $relationship = 'dbas';
protected static ?string $title = 'Trade Names (DBAs)';
protected static ?string $recordTitleAttribute = 'trade_name';
public function form(Schema $schema): Schema
{
return $schema
->components([
Section::make('Basic Information')
->schema([
TextInput::make('trade_name')
->label('Trade Name')
->required()
->maxLength(255),
TextInput::make('slug')
->label('Slug')
->disabled()
->dehydrated(false)
->helperText('Auto-generated from trade name'),
Toggle::make('is_default')
->label('Default DBA')
->helperText('Use for new invoices by default'),
Toggle::make('is_active')
->label('Active')
->default(true),
])
->columns(2),
Section::make('Address')
->schema([
TextInput::make('address')
->label('Street Address')
->maxLength(255),
TextInput::make('address_line_2')
->label('Address Line 2')
->maxLength(255),
Grid::make(3)
->schema([
TextInput::make('city')
->maxLength(255),
TextInput::make('state')
->maxLength(2)
->extraAttributes(['class' => 'uppercase']),
TextInput::make('zip')
->label('ZIP Code')
->maxLength(10),
]),
])
->collapsible(),
Section::make('License Information')
->schema([
TextInput::make('license_number')
->maxLength(255),
TextInput::make('license_type')
->maxLength(255),
DatePicker::make('license_expiration')
->label('Expiration Date'),
])
->columns(3)
->collapsible(),
Section::make('Banking Information')
->description('Sensitive data is encrypted at rest.')
->schema([
TextInput::make('bank_name')
->maxLength(255),
TextInput::make('bank_account_name')
->maxLength(255),
TextInput::make('bank_routing_number')
->maxLength(50)
->password()
->revealable(),
TextInput::make('bank_account_number')
->maxLength(50)
->password()
->revealable(),
Select::make('bank_account_type')
->options([
'checking' => 'Checking',
'savings' => 'Savings',
]),
])
->columns(2)
->collapsible()
->collapsed(),
Section::make('Tax Information')
->description('Sensitive data is encrypted at rest.')
->schema([
TextInput::make('tax_id')
->label('Tax ID')
->maxLength(50)
->password()
->revealable(),
Select::make('tax_id_type')
->label('Tax ID Type')
->options([
'ein' => 'EIN',
'ssn' => 'SSN',
]),
])
->columns(2)
->collapsible()
->collapsed(),
Section::make('Contacts')
->schema([
Grid::make(2)
->schema([
Section::make('Primary Contact')
->schema([
TextInput::make('primary_contact_name')
->label('Name')
->maxLength(255),
TextInput::make('primary_contact_email')
->label('Email')
->email()
->maxLength(255),
TextInput::make('primary_contact_phone')
->label('Phone')
->tel()
->maxLength(50),
]),
Section::make('AP Contact')
->schema([
TextInput::make('ap_contact_name')
->label('Name')
->maxLength(255),
TextInput::make('ap_contact_email')
->label('Email')
->email()
->maxLength(255),
TextInput::make('ap_contact_phone')
->label('Phone')
->tel()
->maxLength(50),
]),
]),
])
->collapsible()
->collapsed(),
Section::make('Invoice Settings')
->schema([
TextInput::make('payment_terms')
->maxLength(50)
->placeholder('Net 30'),
TextInput::make('invoice_prefix')
->maxLength(10)
->placeholder('INV-'),
Textarea::make('payment_instructions')
->rows(2)
->columnSpanFull(),
Textarea::make('invoice_footer')
->rows(2)
->columnSpanFull(),
])
->columns(2)
->collapsible()
->collapsed(),
]);
}
public function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('trade_name')
->label('Trade Name')
->searchable()
->sortable(),
TextColumn::make('city')
->label('Location')
->formatStateUsing(fn ($record) => $record->city && $record->state
? "{$record->city}, {$record->state}"
: ($record->city ?? $record->state ?? '-'))
->sortable(),
TextColumn::make('license_number')
->label('License')
->limit(15)
->tooltip(fn ($state) => $state),
IconColumn::make('is_default')
->label('Default')
->boolean()
->trueIcon('heroicon-o-star')
->falseIcon('heroicon-o-minus')
->trueColor('warning'),
IconColumn::make('is_active')
->label('Active')
->boolean(),
TextColumn::make('created_at')
->label('Created')
->dateTime('M j, Y')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('is_default', 'desc')
->headerActions([
CreateAction::make(),
])
->actions([
EditAction::make(),
DeleteAction::make()
->requiresConfirmation(),
])
->emptyStateHeading('No Trade Names')
->emptyStateDescription('Add a DBA to manage different trade names for invoices and licenses.')
->emptyStateIcon('heroicon-o-building-office-2');
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api;
use App\Events\CrmAgentStatusChanged;
use App\Http\Controllers\Controller;
use App\Models\AgentStatus;
use Illuminate\Http\JsonResponse;
@@ -26,12 +27,77 @@ class AgentStatusController extends Controller
}
$agentStatus = AgentStatus::getOrCreate($user->id, $validated['business_id']);
$oldStatus = $agentStatus->status;
$agentStatus->setStatus($validated['status'], $validated['status_message'] ?? null);
// Broadcast status change if it changed
if ($oldStatus !== $validated['status']) {
broadcast(new CrmAgentStatusChanged($agentStatus->fresh()))->toOthers();
}
return response()->json([
'success' => true,
'status' => $agentStatus->status,
'status_label' => AgentStatus::statuses()[$agentStatus->status],
]);
}
/**
* Heartbeat to maintain online status
*/
public function heartbeat(Request $request): JsonResponse
{
$validated = $request->validate([
'business_id' => 'required|integer|exists:businesses,id',
]);
$user = $request->user();
// Verify user belongs to the business
if (! $user->businesses()->where('businesses.id', $validated['business_id'])->exists()) {
return response()->json(['error' => 'Unauthorized'], 403);
}
$agentStatus = AgentStatus::where('user_id', $user->id)
->where('business_id', $validated['business_id'])
->first();
if ($agentStatus) {
$agentStatus->updateLastSeen();
}
return response()->json(['success' => true]);
}
/**
* Get team members' statuses for a business
*/
public function team(Request $request): JsonResponse
{
$validated = $request->validate([
'business_id' => 'required|integer|exists:businesses,id',
]);
$user = $request->user();
// Verify user belongs to the business
if (! $user->businesses()->where('businesses.id', $validated['business_id'])->exists()) {
return response()->json(['error' => 'Unauthorized'], 403);
}
$statuses = AgentStatus::where('business_id', $validated['business_id'])
->where('status', '!=', AgentStatus::STATUS_OFFLINE)
->where('last_seen_at', '>=', now()->subMinutes(5))
->with('user:id,name')
->get()
->map(fn ($s) => [
'user_id' => $s->user_id,
'user_name' => $s->user?->name,
'status' => $s->status,
'status_message' => $s->status_message,
'last_seen_at' => $s->last_seen_at?->toIso8601String(),
]);
return response()->json(['team' => $statuses]);
}
}

View File

@@ -0,0 +1,237 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\TeamConversation;
use App\Models\TeamMessage;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class TeamChatController extends Controller
{
/**
* Get all team conversations for current user
*/
public function index(Request $request): JsonResponse
{
$validated = $request->validate([
'business_id' => 'required|integer|exists:businesses,id',
]);
$user = $request->user();
// Verify user belongs to business
if (! $user->businesses()->where('businesses.id', $validated['business_id'])->exists()) {
return response()->json(['error' => 'Unauthorized'], 403);
}
$conversations = TeamConversation::forBusiness($validated['business_id'])
->forUser($user->id)
->with(['participants:id,first_name,last_name', 'messages' => fn ($q) => $q->latest()->limit(1)])
->orderByDesc('last_message_at')
->get()
->map(fn ($conv) => $this->formatConversation($conv, $user->id));
return response()->json(['conversations' => $conversations]);
}
/**
* Get or create a direct conversation with another user
*/
public function getOrCreateDirect(Request $request): JsonResponse
{
$validated = $request->validate([
'business_id' => 'required|integer|exists:businesses,id',
'user_id' => 'required|integer|exists:users,id',
]);
$user = $request->user();
// Verify current user belongs to business
if (! $user->businesses()->where('businesses.id', $validated['business_id'])->exists()) {
return response()->json(['error' => 'Unauthorized'], 403);
}
// Verify target user belongs to same business
$targetUser = User::find($validated['user_id']);
if (! $targetUser->businesses()->where('businesses.id', $validated['business_id'])->exists()) {
return response()->json(['error' => 'User not in business'], 400);
}
// Can't chat with yourself
if ($validated['user_id'] === $user->id) {
return response()->json(['error' => 'Cannot chat with yourself'], 400);
}
$conversation = TeamConversation::getOrCreateDirect(
$validated['business_id'],
$user->id,
$validated['user_id']
);
$conversation->load('participants:id,first_name,last_name');
return response()->json([
'conversation' => $this->formatConversation($conversation, $user->id),
]);
}
/**
* Get messages for a conversation
*/
public function messages(Request $request, int $conversationId): JsonResponse
{
$user = $request->user();
$conversation = TeamConversation::with('participants')
->findOrFail($conversationId);
// Verify user is participant
if (! $conversation->participants->contains('id', $user->id)) {
return response()->json(['error' => 'Unauthorized'], 403);
}
$messages = $conversation->messages()
->with('sender:id,first_name,last_name')
->orderBy('created_at')
->limit(100)
->get()
->map(fn ($msg) => $this->formatMessage($msg));
// Mark conversation as read
$conversation->markReadFor($user->id);
return response()->json(['messages' => $messages]);
}
/**
* Send a message to a conversation
*/
public function send(Request $request, int $conversationId): JsonResponse
{
$validated = $request->validate([
'body' => 'required|string|max:10000',
'type' => 'sometimes|string|in:text,file,image',
'metadata' => 'sometimes|array',
]);
$user = $request->user();
$conversation = TeamConversation::with('participants')
->findOrFail($conversationId);
// Verify user is participant
if (! $conversation->participants->contains('id', $user->id)) {
return response()->json(['error' => 'Unauthorized'], 403);
}
$message = TeamMessage::create([
'conversation_id' => $conversationId,
'sender_id' => $user->id,
'body' => $validated['body'],
'type' => $validated['type'] ?? TeamMessage::TYPE_TEXT,
'metadata' => $validated['metadata'] ?? null,
'read_by' => [$user->id], // Sender has read it
]);
$message->load('sender:id,first_name,last_name');
return response()->json([
'message' => $this->formatMessage($message),
]);
}
/**
* Mark conversation as read
*/
public function markRead(Request $request, int $conversationId): JsonResponse
{
$user = $request->user();
$conversation = TeamConversation::with('participants')
->findOrFail($conversationId);
// Verify user is participant
if (! $conversation->participants->contains('id', $user->id)) {
return response()->json(['error' => 'Unauthorized'], 403);
}
$conversation->markReadFor($user->id);
return response()->json(['success' => true]);
}
/**
* Get team members available for chat
*/
public function teamMembers(Request $request): JsonResponse
{
$validated = $request->validate([
'business_id' => 'required|integer|exists:businesses,id',
]);
$user = $request->user();
// Verify user belongs to business
if (! $user->businesses()->where('businesses.id', $validated['business_id'])->exists()) {
return response()->json(['error' => 'Unauthorized'], 403);
}
// Get all users in the business except current user
$members = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $validated['business_id']))
->where('id', '!=', $user->id)
->select('id', 'first_name', 'last_name')
->orderBy('first_name')
->get()
->map(fn ($u) => [
'id' => $u->id,
'name' => $u->name,
'initials' => strtoupper(substr($u->first_name ?? '', 0, 1).substr($u->last_name ?? '', 0, 1)),
]);
return response()->json(['members' => $members]);
}
/**
* Format conversation for API response
*/
private function formatConversation(TeamConversation $conversation, int $currentUserId): array
{
$other = $conversation->getOtherParticipant($currentUserId);
return [
'id' => $conversation->id,
'type' => $conversation->type,
'name' => $conversation->getDisplayName($currentUserId),
'other_user' => $other ? [
'id' => $other->id,
'name' => $other->name,
'initials' => strtoupper(substr($other->first_name ?? '', 0, 1).substr($other->last_name ?? '', 0, 1)),
] : null,
'last_message_preview' => $conversation->last_message_preview,
'last_message_at' => $conversation->last_message_at?->toIso8601String(),
'unread_count' => $conversation->getUnreadCountFor($currentUserId),
'is_pinned' => $conversation->participants->firstWhere('id', $currentUserId)?->pivot?->is_pinned ?? false,
'is_muted' => $conversation->participants->firstWhere('id', $currentUserId)?->pivot?->is_muted ?? false,
];
}
/**
* Format message for API response
*/
private function formatMessage(TeamMessage $message): array
{
return [
'id' => $message->id,
'sender_id' => $message->sender_id,
'sender_name' => $message->getSenderName(),
'sender_initials' => $message->getSenderInitials(),
'body' => $message->body,
'type' => $message->type,
'metadata' => $message->metadata,
'created_at' => $message->created_at->toIso8601String(),
];
}
}

View File

@@ -0,0 +1,96 @@
<?php
namespace App\Http\Controllers;
use App\Models\BannerAd;
use App\Services\BannerAdService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\ImageManager;
class BannerAdController extends Controller
{
public function __construct(
protected BannerAdService $bannerAdService
) {}
/**
* Handle click tracking and redirect
* URL: /ads/click/{bannerAd}
*/
public function click(Request $request, BannerAd $bannerAd)
{
$this->bannerAdService->recordClick($bannerAd, [
'business_id' => auth()->user()?->businesses->first()?->id,
'user_id' => auth()->id(),
'session_id' => session()->getId(),
'page_url' => $request->header('referer'),
]);
return redirect()->away($bannerAd->cta_url);
}
/**
* Track impression via AJAX (for lazy-loaded ads)
* URL: POST /ads/impression/{bannerAd}
*/
public function impression(Request $request, BannerAd $bannerAd)
{
$this->bannerAdService->recordImpression($bannerAd, [
'business_id' => auth()->user()?->businesses->first()?->id,
'user_id' => auth()->id(),
'session_id' => session()->getId(),
]);
return response()->json(['success' => true]);
}
/**
* Serve banner ad image at specific width
* URL: /images/banner-ad/{bannerAd}/{width?}
*/
public function image(BannerAd $bannerAd, ?int $width = null)
{
if (! $bannerAd->image_path || ! Storage::exists($bannerAd->image_path)) {
abort(404);
}
// Return original if no width specified
if (! $width) {
$contents = Storage::get($bannerAd->image_path);
$mimeType = Storage::mimeType($bannerAd->image_path);
return response($contents)
->header('Content-Type', $mimeType)
->header('Cache-Control', 'public, max-age=86400');
}
// Generate and cache resized version
$ext = pathinfo($bannerAd->image_path, PATHINFO_EXTENSION);
$thumbnailName = "banner-ad-{$bannerAd->id}-{$width}w.{$ext}";
$thumbnailPath = "banner-ads/cache/{$thumbnailName}";
if (! Storage::disk('local')->exists($thumbnailPath)) {
$originalContents = Storage::get($bannerAd->image_path);
$manager = new ImageManager(new Driver);
$image = $manager->read($originalContents);
$image->scale(width: $width);
if (! Storage::disk('local')->exists('banner-ads/cache')) {
Storage::disk('local')->makeDirectory('banner-ads/cache');
}
$encoded = $ext === 'png' ? $image->toPng() : $image->toJpeg(quality: 90);
Storage::disk('local')->put($thumbnailPath, $encoded);
}
$mimeType = $ext === 'png' ? 'image/png' : 'image/jpeg';
return response()->file(
storage_path("app/private/{$thumbnailPath}"),
['Content-Type' => $mimeType, 'Cache-Control' => 'public, max-age=86400']
);
}
}

View File

@@ -4,19 +4,24 @@ namespace App\Http\Controllers;
use App\Models\Brand;
use App\Models\Product;
use App\Models\ProductCategory;
use App\Models\Strain;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class MarketplaceController extends Controller
{
/**
* Display marketplace browse page
* Display marketplace browse page (Amazon/Shopify style)
*/
public function index(Request $request)
{
$business = auth()->user()->businesses->first();
$hasFilters = $request->hasAny(['search', 'brand_id', 'strain_type', 'price_min', 'price_max', 'in_stock', 'category_id']);
// Start with active products only
$query = Product::query()
->with(['brand', 'strain'])
->with(['brand', 'strain', 'category'])
->active();
// Search filter (name, SKU, description)
@@ -28,9 +33,15 @@ class MarketplaceController extends Controller
});
}
// Brand filter
if ($brandId = $request->input('brand_id')) {
$query->where('brand_id', $brandId);
// Brand filter (supports multiple)
if ($brandIds = $request->input('brand_id')) {
$brandIds = is_array($brandIds) ? $brandIds : [$brandIds];
$query->whereIn('brand_id', $brandIds);
}
// Category filter (uses category_id foreign key)
if ($categoryId = $request->input('category_id')) {
$query->where('category_id', $categoryId);
}
// Strain type filter
@@ -64,23 +75,96 @@ class MarketplaceController extends Controller
default => $query->latest(),
};
// View mode (grid/list)
$viewMode = $request->input('view', 'grid');
// Paginate results
$products = $query->paginate(12)->withQueryString();
$perPage = $viewMode === 'list' ? 10 : 12;
$products = $query->paginate($perPage)->withQueryString();
// Get all active brands for filters
$brands = Brand::active()->orderBy('name')->get();
// Get brands with product counts for faceted filter
$brands = Brand::query()
->active()
->withCount(['products' => fn ($q) => $q->active()])
->orderBy('name')
->get()
->filter(fn ($b) => $b->products_count > 0);
// Get featured products for carousel (exclude from main results if in first page)
// Get categories with product counts
$categories = ProductCategory::query()
->whereNull('parent_id') // Only top-level categories
->where('is_active', true)
->withCount(['products' => fn ($q) => $q->active()])
->get()
->filter(fn ($c) => $c->products_count > 0)
->sortByDesc('products_count');
// Featured products for hero carousel
$featuredProducts = Product::query()
->with(['brand', 'strain'])
->featured()
->inStock()
->limit(3)
->limit(5)
->get();
$business = auth()->user()->businesses->first();
// Top brands (by product count) for homepage section
$topBrands = Brand::query()
->active()
->withCount(['products' => fn ($q) => $q->active()])
->get()
->filter(fn ($b) => $b->products_count > 0)
->sortByDesc('products_count')
->take(6);
return view('buyer.marketplace.index', compact('products', 'brands', 'featuredProducts', 'business'));
// New arrivals (products created in last 14 days)
$newArrivals = Product::query()
->with(['brand', 'strain', 'category'])
->active()
->inStock()
->where('created_at', '>=', now()->subDays(14))
->orderByDesc('created_at')
->limit(8)
->get();
// Trending products (most ordered in last 30 days - simplified query)
$trendingIds = DB::table('order_items')
->select('product_id', DB::raw('SUM(quantity) as total_sold'))
->where('created_at', '>=', now()->subDays(30))
->groupBy('product_id')
->orderByDesc('total_sold')
->limit(8)
->pluck('product_id');
$trending = $trendingIds->isNotEmpty()
? Product::with(['brand', 'strain', 'category'])
->whereIn('id', $trendingIds)
->active()
->get()
->sortBy(fn ($p) => array_search($p->id, $trendingIds->toArray()))
: collect();
// Active filters for pills display
$activeFilters = collect([
'search' => $request->input('search'),
'brand_id' => $request->input('brand_id'),
'category_id' => $request->input('category_id'),
'strain_type' => $request->input('strain_type'),
'in_stock' => $request->input('in_stock'),
])->filter();
return view('buyer.marketplace.index', compact(
'products',
'brands',
'categories',
'featuredProducts',
'topBrands',
'newArrivals',
'trending',
'business',
'viewMode',
'activeFilters',
'hasFilters'
));
}
/**
@@ -180,7 +264,7 @@ class MarketplaceController extends Controller
// Get featured products from this brand
$featuredProducts = Product::query()
->with(['strain'])
->with(['strain', 'brand'])
->where('brand_id', $brand->id)
->featured()
->inStock()
@@ -189,7 +273,7 @@ class MarketplaceController extends Controller
// Get all products from this brand
$products = Product::query()
->with(['strain'])
->with(['strain', 'brand'])
->where('brand_id', $brand->id)
->active()
->orderBy('is_featured', 'desc')

View File

@@ -97,6 +97,135 @@ class OrderController extends Controller
return view('seller.orders.index', compact('orders', 'business'));
}
/**
* Show the form for creating a new order (seller-initiated).
*/
public function create(\App\Models\Business $business): View
{
// Get all buyer businesses for the customer dropdown
$buyers = \App\Models\Business::where('is_active', true)
->whereIn('business_type', ['buyer', 'both'])
->with(['locations' => function ($query) {
$query->where('is_active', true)->orderBy('is_primary', 'desc')->orderBy('name');
}])
->orderBy('name')
->get();
// Get recently ordered products (last 30 days, top 10 most common)
$recentProducts = \App\Models\Product::forBusiness($business)
->whereHas('orderItems', function ($query) {
$query->where('created_at', '>=', now()->subDays(30));
})
->with(['brand', 'images'])
->withCount(['orderItems' => function ($query) {
$query->where('created_at', '>=', now()->subDays(30));
}])
->orderByDesc('order_items_count')
->take(10)
->get()
->map(function ($product) use ($business) {
// Calculate inventory from InventoryItem model
$totalOnHand = $product->inventoryItems()
->where('business_id', $business->id)
->sum('quantity_on_hand');
$totalAllocated = $product->inventoryItems()
->where('business_id', $business->id)
->sum('quantity_allocated');
return [
'id' => $product->id,
'name' => $product->name,
'sku' => $product->sku,
'brand_name' => $product->brand?->name,
'wholesale_price' => $product->wholesale_price,
'quantity_available' => max(0, $totalOnHand - $totalAllocated),
'type' => $product->type,
];
});
return view('seller.orders.create', compact('business', 'buyers', 'recentProducts'));
}
/**
* Store a newly created order (seller-initiated).
*/
public function store(\App\Models\Business $business, Request $request): RedirectResponse
{
$validated = $request->validate([
'buyer_business_id' => 'required|exists:businesses,id',
'location_id' => 'nullable|exists:locations,id',
'contact_id' => 'nullable|exists:contacts,id',
'payment_terms' => 'required|in:cod,net_15,net_30,net_60,net_90',
'notes' => 'nullable|string|max:1000',
'items' => 'required|array|min:1',
'items.*.product_id' => 'required|exists:products,id',
'items.*.quantity' => 'required|integer|min:1',
'items.*.unit_price' => 'required|numeric|min:0',
'items.*.discount_amount' => 'nullable|numeric|min:0',
'items.*.discount_type' => 'nullable|in:fixed,percent',
'items.*.notes' => 'nullable|string|max:500',
'items.*.batch_id' => 'nullable|exists:batches,id',
]);
try {
// Create the order
$order = Order::create([
'business_id' => $validated['buyer_business_id'],
'location_id' => $validated['location_id'] ?? null,
'contact_id' => $validated['contact_id'] ?? null,
'user_id' => auth()->id(),
'status' => 'new',
'created_by' => 'seller',
'payment_terms' => $validated['payment_terms'],
'notes' => $validated['notes'] ?? null,
]);
// Add line items
$subtotal = 0;
foreach ($validated['items'] as $item) {
$product = \App\Models\Product::findOrFail($item['product_id']);
$lineSubtotal = $item['quantity'] * $item['unit_price'];
$discountAmount = 0;
if (! empty($item['discount_amount']) && $item['discount_amount'] > 0) {
if (($item['discount_type'] ?? 'percent') === 'percent') {
$discountAmount = $lineSubtotal * ($item['discount_amount'] / 100);
} else {
$discountAmount = $item['discount_amount'];
}
}
$lineTotal = $lineSubtotal - $discountAmount;
$subtotal += $lineTotal;
$order->items()->create([
'product_id' => $item['product_id'],
'batch_id' => $item['batch_id'] ?? null,
'quantity' => $item['quantity'],
'price' => $item['unit_price'],
'discount_amount' => $discountAmount,
'total' => $lineTotal,
'notes' => $item['notes'] ?? null,
]);
}
// Update order totals
$order->update([
'subtotal' => $subtotal,
'total' => $subtotal, // Tax can be added later
]);
return redirect()
->route('seller.business.orders.show', [$business->slug, $order])
->with('success', 'Order created successfully!');
} catch (\Exception $e) {
return back()
->withInput()
->with('error', 'Failed to create order: '.$e->getMessage());
}
}
/**
* Display order detail with workorder/picking ticket functionality.
*/
@@ -213,6 +342,41 @@ class OrderController extends Controller
return back()->with('success', "Order {$order->order_number} has been cancelled.");
}
/**
* Update item comment for an order line item.
*/
public function updateItemComment(\App\Models\Business $business, Order $order, \App\Models\OrderItem $orderItem, Request $request): RedirectResponse
{
// Verify the item belongs to this order
if ($orderItem->order_id !== $order->id) {
abort(404);
}
$validated = $request->validate([
'item_comment' => 'nullable|string|max:2000',
]);
$orderItem->update([
'item_comment' => $validated['item_comment'],
]);
return back()->with('success', 'Item comment updated.');
}
/**
* Toggle ping pong mode for an order.
*/
public function togglePingPong(\App\Models\Business $business, Order $order): RedirectResponse
{
$order->update([
'is_ping_pong' => ! $order->is_ping_pong,
]);
$status = $order->is_ping_pong ? 'enabled' : 'disabled';
return back()->with('success', "Ping Pong flow {$status} for this order.");
}
/**
* Approve order for delivery (after buyer selects delivery method).
*/

View File

@@ -55,6 +55,7 @@ class BrandController extends Controller
'is_active' => $brand->is_active,
'is_public' => $brand->is_public,
'is_featured' => $brand->is_featured,
'is_cannaiq_connected' => $brand->isCannaiqConnected(),
'products_count' => $brand->products_count ?? 0,
'updated_at' => $brand->updated_at?->diffForHumans(),
'website_url' => $brand->website_url,
@@ -2099,6 +2100,146 @@ class BrandController extends Controller
]);
}
/**
* Connect brand to CannaiQ API.
*
* Normalizes the brand name and stores as cannaiq_brand_key.
*/
public function cannaiqConnect(Request $request, Business $business, Brand $brand)
{
$this->authorize('update', [$brand, $business]);
$validated = $request->validate([
'brand_name' => 'required|string|max:255',
]);
$brand->connectToCannaiq($validated['brand_name']);
if ($request->wantsJson()) {
return response()->json([
'success' => true,
'message' => 'Brand connected to CannaiQ',
'cannaiq_brand_key' => $brand->cannaiq_brand_key,
]);
}
return redirect()
->route('seller.business.brands.dashboard', [$business->slug, $brand->hashid, 'tab' => 'settings'])
->with('success', 'Brand connected to CannaiQ successfully!');
}
/**
* Disconnect brand from CannaiQ API.
*/
public function cannaiqDisconnect(Request $request, Business $business, Brand $brand)
{
$this->authorize('update', [$brand, $business]);
$brand->disconnectFromCannaiq();
if ($request->wantsJson()) {
return response()->json([
'success' => true,
'message' => 'Brand disconnected from CannaiQ',
]);
}
return redirect()
->route('seller.business.brands.dashboard', [$business->slug, $brand->hashid, 'tab' => 'settings'])
->with('success', 'Brand disconnected from CannaiQ.');
}
/**
* CannaiQ product mapping page.
*
* Shows Hub products for this brand and allows mapping to CannaiQ products.
*/
public function cannaiqMapping(Business $business, Brand $brand)
{
$this->authorize('view', [$brand, $business]);
if (! $brand->isCannaiqConnected()) {
return redirect()
->route('seller.business.brands.dashboard', [$business->slug, $brand->hashid, 'tab' => 'settings'])
->with('error', 'Please connect this brand to CannaiQ first.');
}
$products = $brand->products()
->with('cannaiqMappings')
->where('is_active', true)
->orderBy('name')
->get();
return view('seller.brands.cannaiq-mapping', [
'business' => $business,
'brand' => $brand,
'products' => $products,
]);
}
/**
* Map a Hub product to a CannaiQ product.
*/
public function cannaiqMapProduct(Request $request, Business $business, Brand $brand)
{
$this->authorize('update', [$brand, $business]);
$validated = $request->validate([
'product_id' => 'required|exists:products,id',
'cannaiq_product_id' => 'required|integer',
'cannaiq_product_name' => 'required|string|max:255',
'cannaiq_store_id' => 'nullable|string|max:255',
'cannaiq_store_name' => 'nullable|string|max:255',
]);
// Verify product belongs to this brand
$product = $brand->products()->findOrFail($validated['product_id']);
// Create mapping (ignore if already exists)
$mapping = $product->cannaiqMappings()->firstOrCreate(
['cannaiq_product_id' => $validated['cannaiq_product_id']],
[
'cannaiq_product_name' => $validated['cannaiq_product_name'],
'cannaiq_store_id' => $validated['cannaiq_store_id'] ?? null,
'cannaiq_store_name' => $validated['cannaiq_store_name'] ?? null,
]
);
if ($request->wantsJson()) {
return response()->json([
'success' => true,
'mapping' => $mapping,
]);
}
return redirect()
->route('seller.business.brands.cannaiq.mapping', [$business->slug, $brand->hashid])
->with('success', 'Product mapped successfully.');
}
/**
* Remove a product mapping.
*/
public function cannaiqUnmapProduct(Request $request, Business $business, Brand $brand, \App\Models\ProductCannaiqMapping $mapping)
{
$this->authorize('update', [$brand, $business]);
// Verify mapping belongs to a product of this brand
if ($mapping->product->brand_id !== $brand->id) {
abort(403);
}
$mapping->delete();
if ($request->wantsJson()) {
return response()->json(['success' => true]);
}
return redirect()
->route('seller.business.brands.cannaiq.mapping', [$business->slug, $brand->hashid])
->with('success', 'Mapping removed.');
}
/**
* Calculate store/distribution metrics for the brand.
*

View File

@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Models\Brand;
use App\Models\Business;
use App\Models\Crm\CrmThread;
use App\Models\Order;
use App\Models\Product;
use App\Models\Promotion;
@@ -268,19 +269,26 @@ class BrandPortalController extends Controller
/**
* Inbox - Conversations (messaging).
*
* Shows conversations related to the business.
* Uses existing messaging infrastructure but scoped to Brand Portal context.
* Shows CRM threads related to the user's linked brands only.
* Uses CrmThread scoped by brand_id for filtering.
*/
public function inbox(Request $request, Business $business)
{
$brandIds = $this->validateAccessAndGetBrandIds($business);
$brands = Brand::whereIn('id', $brandIds)->get();
// For inbox, we show conversations but in a limited Brand Portal context
// This integrates with existing messaging system
// Get threads filtered to only those related to linked brands
$threads = CrmThread::forBusiness($business->id)
->forBrandPortal($brandIds)
->with(['contact', 'assignee', 'brand'])
->withCount('messages')
->orderByDesc('last_message_at')
->paginate(25);
return view('seller.brand-portal.inbox', compact(
'business',
'brands'
'brands',
'threads'
));
}

View File

@@ -39,11 +39,16 @@ class AccountController extends Controller
});
}
// Status filter - default to approved, but allow viewing all
if ($request->filled('status') && $request->status !== 'all') {
$query->where('status', $request->status);
} else {
$query->where('status', 'approved');
// Only show approved accounts (approved buyers)
$query->where('status', 'approved');
// Active/Inactive filter
if ($request->filled('status')) {
if ($request->status === 'active') {
$query->where('is_active', true);
} elseif ($request->status === 'inactive') {
$query->where('is_active', false);
}
}
$accounts = $query->orderBy('name')->paginate(25);

View File

@@ -165,7 +165,7 @@ class InvoiceController extends Controller
'title' => 'required|string|max:255',
'contact_id' => 'required|exists:contacts,id',
'account_id' => 'nullable|exists:businesses,id',
'location_id' => 'nullable|exists:business_locations,id',
'location_id' => 'nullable|exists:locations,id',
'quote_id' => 'nullable|exists:crm_quotes,id',
'deal_id' => 'nullable|exists:crm_deals,id',
'due_date' => 'required|date|after_or_equal:today',
@@ -309,7 +309,7 @@ class InvoiceController extends Controller
'title' => 'required|string|max:255',
'contact_id' => 'required|exists:contacts,id',
'account_id' => 'nullable|exists:businesses,id',
'location_id' => 'nullable|exists:business_locations,id',
'location_id' => 'nullable|exists:locations,id',
'deal_id' => 'nullable|exists:crm_deals,id',
'due_date' => 'required|date',
'tax_rate' => 'nullable|numeric|min:0|max:100',

View File

@@ -246,7 +246,7 @@ class QuoteController extends Controller
abort(404);
}
$quote->load(['contact', 'account', 'deal', 'creator', 'items.product', 'invoice', 'files']);
$quote->load(['contact', 'account', 'deal', 'creator', 'items.product.brand', 'invoice', 'files']);
return view('seller.crm.quotes.show', compact('quote', 'business'));
}
@@ -581,7 +581,7 @@ class QuoteController extends Controller
'sellerBusiness' => $business,
]);
return $pdf->inline("{$quote->quote_number}.pdf");
return $pdf->stream("{$quote->quote_number}.pdf");
}
/**

View File

@@ -2,17 +2,24 @@
namespace App\Http\Controllers\Seller\Crm;
use App\Events\CrmTypingIndicator;
use App\Http\Controllers\Controller;
use App\Models\AgentStatus;
use App\Models\Business;
use App\Models\ChatQuickReply;
use App\Models\Contact;
use App\Models\Crm\CrmActiveView;
use App\Models\Crm\CrmChannel;
use App\Models\Crm\CrmInternalNote;
use App\Models\Crm\CrmThread;
use App\Models\SalesRepAssignment;
use App\Models\User;
use App\Services\Crm\CrmAiService;
use App\Services\Crm\CrmChannelService;
use App\Services\Crm\CrmSlaService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class ThreadController extends Controller
{
@@ -446,4 +453,363 @@ class ThreadController extends Controller
]),
]);
}
// ========================================
// API Endpoints for Real-Time Inbox
// ========================================
/**
* API: Get threads list for real-time updates
*/
public function apiIndex(Request $request, Business $business): JsonResponse
{
$user = $request->user();
$query = CrmThread::forBusiness($business->id)
->with(['contact:id,first_name,last_name,email,phone', 'assignee:id,name', 'channel:id,type,name', 'account:id,name'])
->withCount('messages');
// Apply "my accounts" filter for sales reps
if ($request->boolean('my_accounts')) {
$query->forSalesRep($business->id, $user->id);
}
// Status filter
if ($request->filled('status') && $request->status !== 'all') {
$query->where('status', $request->status);
}
// Channel filter
if ($request->filled('channel') && $request->channel !== 'all') {
$query->where('last_channel_type', $request->channel);
}
// Assigned filter
if ($request->filled('assigned')) {
if ($request->assigned === 'me') {
$query->where('assigned_to', $user->id);
} elseif ($request->assigned === 'unassigned') {
$query->whereNull('assigned_to');
} elseif (is_numeric($request->assigned)) {
$query->where('assigned_to', $request->assigned);
}
}
// Search
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('subject', 'ilike', "%{$search}%")
->orWhere('last_message_preview', 'ilike', "%{$search}%")
->orWhereHas('contact', fn ($c) => $c->whereRaw("CONCAT(first_name, ' ', last_name) ILIKE ?", ["%{$search}%"]))
->orWhereHas('account', fn ($a) => $a->where('name', 'ilike', "%{$search}%"));
});
}
$threads = $query->orderByDesc('last_message_at')
->limit($request->input('limit', 50))
->get();
return response()->json([
'threads' => $threads->map(fn ($t) => [
'id' => $t->id,
'subject' => $t->subject,
'status' => $t->status,
'priority' => $t->priority,
'is_read' => $t->is_read,
'last_message_at' => $t->last_message_at?->toIso8601String(),
'last_message_preview' => $t->last_message_preview,
'last_message_direction' => $t->last_message_direction,
'last_channel_type' => $t->last_channel_type,
'contact' => $t->contact ? [
'id' => $t->contact->id,
'name' => $t->contact->getFullName(),
'email' => $t->contact->email,
'phone' => $t->contact->phone,
] : null,
'account' => $t->account ? [
'id' => $t->account->id,
'name' => $t->account->name,
] : null,
'assignee' => $t->assignee ? [
'id' => $t->assignee->id,
'name' => $t->assignee->name,
] : null,
'messages_count' => $t->messages_count,
]),
]);
}
/**
* API: Get messages for a thread
*/
public function apiMessages(Request $request, Business $business, CrmThread $thread): JsonResponse
{
if ($thread->business_id !== $business->id) {
return response()->json(['error' => 'Unauthorized'], 403);
}
$query = $thread->messages()
->with(['user:id,name', 'attachments'])
->orderBy('created_at', 'asc');
// Pagination for infinite scroll
if ($request->filled('before_id')) {
$query->where('id', '<', $request->before_id);
}
$messages = $query->limit($request->input('limit', 50))->get();
// Mark thread as read
if ($messages->isNotEmpty()) {
$thread->markAsRead($request->user());
}
return response()->json([
'messages' => $messages->map(fn ($m) => [
'id' => $m->id,
'body' => $m->body,
'body_html' => $m->body_html,
'direction' => $m->direction,
'channel_type' => $m->channel_type,
'sender_id' => $m->user_id,
'sender_name' => $m->user?->name ?? ($m->direction === 'inbound' ? $thread->contact?->getFullName() : 'System'),
'status' => $m->status,
'created_at' => $m->created_at->toIso8601String(),
'attachments' => $m->attachments->map(fn ($a) => [
'id' => $a->id,
'filename' => $a->original_filename ?? $a->filename,
'mime_type' => $a->mime_type,
'size' => $a->size,
'url' => Storage::disk($a->disk ?? 'minio')->url($a->path),
]),
]),
'has_more' => $messages->count() === $request->input('limit', 50),
'thread' => [
'id' => $thread->id,
'subject' => $thread->subject,
'status' => $thread->status,
'contact' => $thread->contact ? [
'id' => $thread->contact->id,
'name' => $thread->contact->getFullName(),
'email' => $thread->contact->email,
'phone' => $thread->contact->phone,
] : null,
],
]);
}
/**
* API: Send typing indicator
*/
public function typing(Request $request, Business $business, CrmThread $thread): JsonResponse
{
if ($thread->business_id !== $business->id) {
return response()->json(['error' => 'Unauthorized'], 403);
}
$validated = $request->validate([
'is_typing' => 'required|boolean',
]);
broadcast(new CrmTypingIndicator(
threadId: $thread->id,
userId: $request->user()->id,
userName: $request->user()->name,
isTyping: $validated['is_typing']
))->toOthers();
// Update active view type
CrmActiveView::startViewing(
$thread,
$request->user(),
$validated['is_typing'] ? CrmActiveView::VIEW_TYPE_TYPING : CrmActiveView::VIEW_TYPE_VIEWING
);
return response()->json(['success' => true]);
}
/**
* API: Get quick replies
*/
public function quickReplies(Request $request, Business $business): JsonResponse
{
$quickReplies = ChatQuickReply::where('business_id', $business->id)
->where('is_active', true)
->orderByDesc('usage_count')
->orderBy('sort_order')
->get()
->groupBy('category');
return response()->json([
'quick_replies' => $quickReplies,
]);
}
/**
* API: Use a quick reply (increment usage count)
*/
public function useQuickReply(Request $request, Business $business, ChatQuickReply $quickReply): JsonResponse
{
if ($quickReply->business_id !== $business->id) {
return response()->json(['error' => 'Unauthorized'], 403);
}
// Increment usage count
$quickReply->increment('usage_count');
// Process template variables
$message = $quickReply->message;
if ($request->filled('contact_id')) {
$contact = Contact::find($request->contact_id);
if ($contact) {
$message = str_replace(
['{{name}}', '{{first_name}}', '{{last_name}}', '{{company}}'],
[$contact->getFullName(), $contact->first_name, $contact->last_name, $contact->business?->name ?? ''],
$message
);
}
}
return response()->json([
'message' => $message,
'label' => $quickReply->label,
]);
}
/**
* API: Get contact details with email engagement
*/
public function apiContact(Request $request, Business $business, CrmThread $thread): JsonResponse
{
if ($thread->business_id !== $business->id) {
return response()->json(['error' => 'Unauthorized'], 403);
}
$contact = $thread->contact;
if (! $contact) {
return response()->json(['contact' => null]);
}
// Get recent email engagement
$emailEngagement = [];
if (class_exists(\App\Models\Analytics\EmailInteraction::class)) {
$emailEngagement = \App\Models\Analytics\EmailInteraction::where(function ($q) use ($contact) {
$q->where('recipient_email', $contact->email);
if ($contact->user_id) {
$q->orWhere('recipient_user_id', $contact->user_id);
}
})
->whereNotNull('first_opened_at')
->with('emailCampaign:id,subject')
->orderByDesc('first_opened_at')
->limit(10)
->get()
->map(fn ($i) => [
'id' => $i->id,
'campaign_subject' => $i->emailCampaign?->subject ?? 'Unknown Campaign',
'opened_at' => $i->first_opened_at?->toIso8601String(),
'open_count' => $i->open_count,
'clicked_at' => $i->first_clicked_at?->toIso8601String(),
'click_count' => $i->click_count,
]);
}
// Get recent orders from this contact's account
$recentOrders = [];
if ($thread->account_id) {
$recentOrders = \App\Models\Order::where('business_id', $thread->account_id)
->whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
->orderByDesc('created_at')
->limit(5)
->get()
->map(fn ($o) => [
'id' => $o->id,
'hashid' => $o->hashid,
'total' => $o->total,
'status' => $o->status,
'created_at' => $o->created_at->toIso8601String(),
]);
}
return response()->json([
'contact' => [
'id' => $contact->id,
'name' => $contact->getFullName(),
'email' => $contact->email,
'phone' => $contact->phone,
'title' => $contact->title,
'contact_type' => $contact->contact_type,
],
'account' => $thread->account ? [
'id' => $thread->account->id,
'name' => $thread->account->name,
'address' => $thread->account->full_address ?? null,
] : null,
'email_engagement' => $emailEngagement,
'recent_orders' => $recentOrders,
]);
}
/**
* Unified inbox view (Chatwoot-style)
*/
public function unified(Request $request, Business $business)
{
$user = $request->user();
// Get initial threads
$query = CrmThread::forBusiness($business->id)
->with(['contact:id,first_name,last_name,email,phone', 'assignee:id,name', 'account:id,name'])
->withCount('messages')
->orderByDesc('last_message_at')
->limit(50);
$threads = $query->get();
// Get team members with their status
$teamMemberStatuses = AgentStatus::where('business_id', $business->id)
->where('last_seen_at', '>=', now()->subMinutes(5))
->pluck('status', 'user_id');
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
->select('id', 'first_name', 'last_name')
->get()
->map(fn ($member) => [
'id' => $member->id,
'name' => trim($member->first_name.' '.$member->last_name),
'status' => $teamMemberStatuses[$member->id] ?? 'offline',
]);
// Get agent status
$agentStatus = AgentStatus::where('business_id', $business->id)
->where('user_id', $user->id)
->first();
// Get quick replies
$quickReplies = ChatQuickReply::where('business_id', $business->id)
->where('is_active', true)
->orderByDesc('usage_count')
->get()
->groupBy('category');
// Get channels
$channels = $this->channelService->getAvailableChannels($business->id);
// Check if user has sales rep assignments (for "My Accounts" filter)
$hasSalesRepAssignments = SalesRepAssignment::where('business_id', $business->id)
->where('user_id', $user->id)
->exists();
return view('seller.crm.inbox.unified', compact(
'business',
'threads',
'teamMembers',
'agentStatus',
'quickReplies',
'channels',
'hasSalesRepAssignments'
));
}
}

View File

@@ -881,9 +881,9 @@ class ProductController extends Controller
'content' => [
'description' => ['nullable', 'string', 'max:255'],
'tagline' => ['nullable', 'string', 'max:100'],
'long_description' => ['nullable', 'string', 'max:5000'],
'consumer_long_description' => ['nullable', 'string', 'max:5000'],
'buyer_long_description' => ['nullable', 'string', 'max:5000'],
'long_description' => ['nullable', 'string'],
'consumer_long_description' => ['nullable', 'string'],
'buyer_long_description' => ['nullable', 'string'],
'product_link' => 'nullable|url|max:255',
'creatives_json' => 'nullable|json',
'seo_title' => ['nullable', 'string', 'max:70'],

View File

@@ -0,0 +1,263 @@
<?php
namespace App\Http\Controllers\Seller\Settings;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\BusinessDba;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class DbaController extends Controller
{
/**
* Display a listing of all DBAs for the business.
*/
public function index(Business $business): View
{
$dbas = $business->dbas()
->orderByDesc('is_default')
->orderBy('trade_name')
->get();
return view('seller.settings.dbas.index', compact('business', 'dbas'));
}
/**
* Show the form for creating a new DBA.
*/
public function create(Business $business): View
{
return view('seller.settings.dbas.create', compact('business'));
}
/**
* Store a newly created DBA in storage.
*/
public function store(Request $request, Business $business): RedirectResponse
{
$validated = $request->validate([
// Identity
'trade_name' => 'required|string|max:255',
// Address
'address' => 'nullable|string|max:255',
'address_line_2' => 'nullable|string|max:255',
'city' => 'nullable|string|max:255',
'state' => 'nullable|string|max:2',
'zip' => 'nullable|string|max:10',
// License
'license_number' => 'nullable|string|max:255',
'license_type' => 'nullable|string|max:255',
'license_expiration' => 'nullable|date',
// Bank Info
'bank_name' => 'nullable|string|max:255',
'bank_account_name' => 'nullable|string|max:255',
'bank_routing_number' => 'nullable|string|max:50',
'bank_account_number' => 'nullable|string|max:50',
'bank_account_type' => 'nullable|string|in:checking,savings',
// Tax
'tax_id' => 'nullable|string|max:50',
'tax_id_type' => 'nullable|string|in:ein,ssn',
// Contacts
'primary_contact_name' => 'nullable|string|max:255',
'primary_contact_email' => 'nullable|email|max:255',
'primary_contact_phone' => 'nullable|string|max:50',
'ap_contact_name' => 'nullable|string|max:255',
'ap_contact_email' => 'nullable|email|max:255',
'ap_contact_phone' => 'nullable|string|max:50',
// Invoice Settings
'payment_terms' => 'nullable|string|max:50',
'payment_instructions' => 'nullable|string|max:2000',
'invoice_footer' => 'nullable|string|max:2000',
'invoice_prefix' => 'nullable|string|max:10',
// Branding
'logo_path' => 'nullable|string|max:255',
'brand_colors' => 'nullable|array',
// Status
'is_default' => 'boolean',
'is_active' => 'boolean',
]);
$validated['business_id'] = $business->id;
$validated['is_default'] = $request->boolean('is_default');
$validated['is_active'] = $request->boolean('is_active', true);
$dba = BusinessDba::create($validated);
return redirect()
->route('seller.business.settings.dbas.index', $business)
->with('success', "DBA \"{$dba->trade_name}\" created successfully.");
}
/**
* Show the form for editing the specified DBA.
*/
public function edit(Business $business, BusinessDba $dba): View
{
// Verify DBA belongs to this business
if ($dba->business_id !== $business->id) {
abort(403, 'This DBA does not belong to your business.');
}
return view('seller.settings.dbas.edit', compact('business', 'dba'));
}
/**
* Update the specified DBA in storage.
*/
public function update(Request $request, Business $business, BusinessDba $dba): RedirectResponse
{
// Verify DBA belongs to this business
if ($dba->business_id !== $business->id) {
abort(403, 'This DBA does not belong to your business.');
}
$validated = $request->validate([
// Identity
'trade_name' => 'required|string|max:255',
// Address
'address' => 'nullable|string|max:255',
'address_line_2' => 'nullable|string|max:255',
'city' => 'nullable|string|max:255',
'state' => 'nullable|string|max:2',
'zip' => 'nullable|string|max:10',
// License
'license_number' => 'nullable|string|max:255',
'license_type' => 'nullable|string|max:255',
'license_expiration' => 'nullable|date',
// Bank Info
'bank_name' => 'nullable|string|max:255',
'bank_account_name' => 'nullable|string|max:255',
'bank_routing_number' => 'nullable|string|max:50',
'bank_account_number' => 'nullable|string|max:50',
'bank_account_type' => 'nullable|string|in:checking,savings',
// Tax
'tax_id' => 'nullable|string|max:50',
'tax_id_type' => 'nullable|string|in:ein,ssn',
// Contacts
'primary_contact_name' => 'nullable|string|max:255',
'primary_contact_email' => 'nullable|email|max:255',
'primary_contact_phone' => 'nullable|string|max:50',
'ap_contact_name' => 'nullable|string|max:255',
'ap_contact_email' => 'nullable|email|max:255',
'ap_contact_phone' => 'nullable|string|max:50',
// Invoice Settings
'payment_terms' => 'nullable|string|max:50',
'payment_instructions' => 'nullable|string|max:2000',
'invoice_footer' => 'nullable|string|max:2000',
'invoice_prefix' => 'nullable|string|max:10',
// Branding
'logo_path' => 'nullable|string|max:255',
'brand_colors' => 'nullable|array',
// Status
'is_default' => 'boolean',
'is_active' => 'boolean',
]);
$validated['is_default'] = $request->boolean('is_default');
$validated['is_active'] = $request->boolean('is_active', true);
// Don't overwrite encrypted fields if left blank (preserve existing values)
$encryptedFields = ['bank_routing_number', 'bank_account_number', 'tax_id'];
foreach ($encryptedFields as $field) {
if (empty($validated[$field])) {
unset($validated[$field]);
}
}
$dba->update($validated);
return redirect()
->route('seller.business.settings.dbas.index', $business)
->with('success', "DBA \"{$dba->trade_name}\" updated successfully.");
}
/**
* Remove the specified DBA from storage.
*/
public function destroy(Business $business, BusinessDba $dba): RedirectResponse
{
// Verify DBA belongs to this business
if ($dba->business_id !== $business->id) {
abort(403, 'This DBA does not belong to your business.');
}
// Check if this is the only active DBA
$activeCount = $business->dbas()->where('is_active', true)->count();
if ($activeCount <= 1 && $dba->is_active) {
return redirect()
->route('seller.business.settings.dbas.index', $business)
->with('error', 'You cannot delete the only active DBA. Create another DBA first or deactivate this one.');
}
$tradeName = $dba->trade_name;
$dba->delete();
return redirect()
->route('seller.business.settings.dbas.index', $business)
->with('success', "DBA \"{$tradeName}\" deleted successfully.");
}
/**
* Set the specified DBA as the default for the business.
*/
public function setDefault(Business $business, BusinessDba $dba): RedirectResponse
{
// Verify DBA belongs to this business
if ($dba->business_id !== $business->id) {
abort(403, 'This DBA does not belong to your business.');
}
$dba->markAsDefault();
return redirect()
->route('seller.business.settings.dbas.index', $business)
->with('success', "\"{$dba->trade_name}\" is now your default DBA for invoices.");
}
/**
* Toggle the active status of a DBA.
*/
public function toggleActive(Business $business, BusinessDba $dba): RedirectResponse
{
// Verify DBA belongs to this business
if ($dba->business_id !== $business->id) {
abort(403, 'This DBA does not belong to your business.');
}
// Prevent deactivating if it's the only active DBA
if ($dba->is_active) {
$activeCount = $business->dbas()->where('is_active', true)->count();
if ($activeCount <= 1) {
return redirect()
->route('seller.business.settings.dbas.index', $business)
->with('error', 'You cannot deactivate the only active DBA.');
}
}
$dba->update(['is_active' => ! $dba->is_active]);
$status = $dba->is_active ? 'activated' : 'deactivated';
return redirect()
->route('seller.business.settings.dbas.index', $business)
->with('success', "DBA \"{$dba->trade_name}\" has been {$status}.");
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Jobs;
use App\Models\BannerAdDailyStat;
use App\Models\BannerAdEvent;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class RollupBannerAdStats implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
protected ?string $date = null
) {
$this->date = $date ?? now()->subDay()->toDateString();
}
public function handle(): void
{
$stats = BannerAdEvent::query()
->whereDate('created_at', $this->date)
->select([
'banner_ad_id',
DB::raw("SUM(CASE WHEN event_type = 'impression' THEN 1 ELSE 0 END) as impressions"),
DB::raw("SUM(CASE WHEN event_type = 'click' THEN 1 ELSE 0 END) as clicks"),
DB::raw("COUNT(DISTINCT CASE WHEN event_type = 'impression' THEN session_id END) as unique_impressions"),
DB::raw("COUNT(DISTINCT CASE WHEN event_type = 'click' THEN session_id END) as unique_clicks"),
])
->groupBy('banner_ad_id')
->get();
$created = 0;
foreach ($stats as $stat) {
BannerAdDailyStat::updateOrCreate(
[
'banner_ad_id' => $stat->banner_ad_id,
'date' => $this->date,
],
[
'impressions' => $stat->impressions,
'clicks' => $stat->clicks,
'unique_impressions' => $stat->unique_impressions,
'unique_clicks' => $stat->unique_clicks,
]
);
$created++;
}
if ($created > 0) {
Log::info("Banner ad daily stats rolled up: {$created} records for {$this->date}");
}
// Optionally clean up old events (older than 30 days)
$deleted = BannerAdEvent::where('created_at', '<', now()->subDays(30))->delete();
if ($deleted > 0) {
Log::info("Cleaned up {$deleted} old banner ad events");
}
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Jobs;
use App\Services\BannerAdService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class UpdateBannerAdStatuses implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function handle(BannerAdService $service): void
{
$updated = $service->updateScheduledStatuses();
if ($updated > 0) {
Log::info("Banner ad statuses updated: {$updated} ads changed");
}
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Mail\Concerns;
use App\Models\Business;
use App\Models\User;
use Illuminate\Mail\Mailables\Address;
/**
* Trait for adding Reply-To header using business's primary email identity.
*
* This ensures replies to transactional emails (quotes, invoices, orders)
* are routed back to the CRM inbox.
*
* Supports plus addressing: inbox+user123@domain.com routes replies
* to the specific user who sent the original message.
*/
trait HasBusinessReplyTo
{
/**
* Get the Reply-To addresses for the business.
*
* @param User|int|null $user Optional user for plus addressing
* @return array<Address>
*/
protected function getBusinessReplyTo(Business $business, User|int|null $user = null): array
{
$inboundEmail = $business->primaryEmailIdentity?->email;
if (! $inboundEmail) {
return [];
}
// Add plus addressing for user routing
if ($user) {
$userId = $user instanceof User ? $user->id : $user;
$inboundEmail = $this->addPlusAddress($inboundEmail, "u{$userId}");
}
return [new Address($inboundEmail, $business->name)];
}
/**
* Add plus addressing to an email.
* inbox@domain.com + "u123" => inbox+u123@domain.com
*/
protected function addPlusAddress(string $email, string $tag): string
{
$parts = explode('@', $email);
if (count($parts) !== 2) {
return $email;
}
return $parts[0].'+'.$tag.'@'.$parts[1];
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Mail\Invoices;
use App\Mail\Concerns\HasBusinessReplyTo;
use App\Models\Invoice;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Attachment;
@@ -12,7 +13,7 @@ use Illuminate\Queue\SerializesModels;
class InvoiceSentMail extends Mailable
{
use Queueable, SerializesModels;
use HasBusinessReplyTo, Queueable, SerializesModels;
public function __construct(
public Invoice $invoice,
@@ -22,9 +23,19 @@ class InvoiceSentMail extends Mailable
public function envelope(): Envelope
{
return new Envelope(
subject: "Invoice {$this->invoice->invoice_number} from ".config('app.name'),
$business = $this->invoice->sellerBusiness;
$envelope = new Envelope(
subject: "Invoice {$this->invoice->invoice_number} from ".($business?->name ?? config('app.name')),
);
if ($business) {
$replyTo = $this->getBusinessReplyTo($business);
if ($replyTo) {
$envelope->replyTo($replyTo);
}
}
return $envelope;
}
public function content(): Content

View File

@@ -2,6 +2,7 @@
namespace App\Mail\Orders;
use App\Mail\Concerns\HasBusinessReplyTo;
use App\Models\Order;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
@@ -11,7 +12,7 @@ use Illuminate\Queue\SerializesModels;
class OrderAcceptedMail extends Mailable
{
use Queueable, SerializesModels;
use HasBusinessReplyTo, Queueable, SerializesModels;
/**
* Create a new message instance.
@@ -25,9 +26,19 @@ class OrderAcceptedMail extends Mailable
*/
public function envelope(): Envelope
{
return new Envelope(
$business = $this->order->sellerBusiness;
$envelope = new Envelope(
subject: "Order {$this->order->order_number} Accepted",
);
if ($business) {
$replyTo = $this->getBusinessReplyTo($business);
if ($replyTo) {
$envelope->replyTo($replyTo);
}
}
return $envelope;
}
/**

View File

@@ -2,6 +2,7 @@
namespace App\Mail\Orders;
use App\Mail\Concerns\HasBusinessReplyTo;
use App\Models\Order;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
@@ -11,7 +12,7 @@ use Illuminate\Queue\SerializesModels;
class OrderDeliveredMail extends Mailable
{
use Queueable, SerializesModels;
use HasBusinessReplyTo, Queueable, SerializesModels;
public function __construct(
public Order $order
@@ -19,9 +20,19 @@ class OrderDeliveredMail extends Mailable
public function envelope(): Envelope
{
return new Envelope(
$business = $this->order->sellerBusiness;
$envelope = new Envelope(
subject: "Order {$this->order->order_number} Delivered",
);
if ($business) {
$replyTo = $this->getBusinessReplyTo($business);
if ($replyTo) {
$envelope->replyTo($replyTo);
}
}
return $envelope;
}
public function content(): Content

View File

@@ -2,6 +2,7 @@
namespace App\Mail\Orders;
use App\Mail\Concerns\HasBusinessReplyTo;
use App\Models\Order;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
@@ -11,7 +12,7 @@ use Illuminate\Queue\SerializesModels;
class OrderReadyForDeliveryMail extends Mailable
{
use Queueable, SerializesModels;
use HasBusinessReplyTo, Queueable, SerializesModels;
public function __construct(
public Order $order
@@ -19,9 +20,19 @@ class OrderReadyForDeliveryMail extends Mailable
public function envelope(): Envelope
{
return new Envelope(
$business = $this->order->sellerBusiness;
$envelope = new Envelope(
subject: "Order {$this->order->order_number} Ready for Delivery",
);
if ($business) {
$replyTo = $this->getBusinessReplyTo($business);
if ($replyTo) {
$envelope->replyTo($replyTo);
}
}
return $envelope;
}
public function content(): Content

View File

@@ -2,6 +2,7 @@
namespace App\Mail;
use App\Mail\Concerns\HasBusinessReplyTo;
use App\Models\Business;
use App\Models\Crm\CrmQuote;
use Illuminate\Bus\Queueable;
@@ -14,7 +15,7 @@ use Illuminate\Support\Facades\Storage;
class QuoteMail extends Mailable
{
use Queueable, SerializesModels;
use HasBusinessReplyTo, Queueable, SerializesModels;
public function __construct(
public CrmQuote $quote,
@@ -25,9 +26,16 @@ class QuoteMail extends Mailable
public function envelope(): Envelope
{
return new Envelope(
$envelope = new Envelope(
subject: "Quote {$this->quote->quote_number} from {$this->business->name}",
);
$replyTo = $this->getBusinessReplyTo($this->business);
if ($replyTo) {
$envelope->replyTo($replyTo);
}
return $envelope;
}
public function content(): Content

View File

@@ -57,6 +57,11 @@ class Activity extends Model
'task.reminder_sent' => ['icon' => 'heroicons--bell', 'color' => 'text-warning', 'label' => 'Reminder Sent'],
'event.reminder_sent' => ['icon' => 'heroicons--bell', 'color' => 'text-warning', 'label' => 'Reminder Sent'],
// Email engagement activities
'email.opened' => ['icon' => 'heroicons--envelope-open', 'color' => 'text-success', 'label' => 'Email Opened'],
'email.clicked' => ['icon' => 'heroicons--cursor-arrow-rays', 'color' => 'text-info', 'label' => 'Email Link Clicked'],
'email.bounced' => ['icon' => 'heroicons--exclamation-circle', 'color' => 'text-error', 'label' => 'Email Bounced'],
// Generic
'note.added' => ['icon' => 'heroicons--document-text', 'color' => 'text-base-content', 'label' => 'Note Added'],
];

View File

@@ -90,4 +90,22 @@ class AgentStatus extends Model
{
return self::statusColors()[$this->status] ?? 'ghost';
}
/**
* Update last_seen_at timestamp (for heartbeat)
*/
public function updateLastSeen(): self
{
$this->update(['last_seen_at' => now()]);
return $this;
}
/**
* Check if agent is active (seen within last 5 minutes)
*/
public function isActive(): bool
{
return $this->last_seen_at && $this->last_seen_at->gte(now()->subMinutes(5));
}
}

View File

@@ -2,7 +2,9 @@
namespace App\Models\Analytics;
use App\Models\Activity;
use App\Models\Business;
use App\Models\Contact;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -118,6 +120,9 @@ class EmailInteraction extends Model
// Update campaign stats
if ($isFirstOpen && $this->campaign) {
$this->campaign->increment('opened_count');
// Log activity for first open only
$this->logActivity('email.opened', "Opened '{$this->campaign->subject}'");
}
}
@@ -147,6 +152,10 @@ class EmailInteraction extends Model
// Update campaign stats
if ($isFirstClick && $this->campaign) {
$this->campaign->increment('clicked_count');
// Log activity for first click only
$subject = $this->campaign->subject;
$this->logActivity('email.clicked', "Clicked link in '{$subject}'", ['url' => $url]);
}
}
@@ -179,4 +188,32 @@ class EmailInteraction extends Model
$this->update(['engagement_score' => min(100, $score)]);
}
/**
* Log activity for email engagement.
* Finds contact by email and logs activity to their timeline.
*/
protected function logActivity(string $type, string $description, ?array $meta = null): void
{
// Find contact by email in the sender's business
$contact = Contact::where('email', $this->recipient_email)->first();
if (! $contact || ! $this->business_id) {
return;
}
Activity::create([
'seller_business_id' => $this->business_id,
'business_id' => $contact->business_id,
'contact_id' => $contact->id,
'subject_type' => self::class,
'subject_id' => $this->id,
'type' => $type,
'description' => $description,
'meta' => array_merge($meta ?? [], [
'campaign_id' => $this->email_campaign_id,
'interaction_id' => $this->id,
]),
]);
}
}

201
app/Models/BannerAd.php Normal file
View File

@@ -0,0 +1,201 @@
<?php
namespace App\Models;
use App\Enums\BannerAdStatus;
use App\Enums\BannerAdZone;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Storage;
class BannerAd extends Model
{
use SoftDeletes;
protected $fillable = [
'brand_id',
'created_by_user_id',
'name',
'headline',
'description',
'cta_text',
'cta_url',
'image_path',
'image_alt',
'zone',
'starts_at',
'ends_at',
'target_business_types',
'is_platform_wide',
'status',
'priority',
'weight',
'impressions',
'clicks',
];
protected $casts = [
'starts_at' => 'datetime',
'ends_at' => 'datetime',
'target_business_types' => 'array',
'is_platform_wide' => 'boolean',
'status' => BannerAdStatus::class,
'zone' => BannerAdZone::class,
'impressions' => 'integer',
'clicks' => 'integer',
'priority' => 'integer',
'weight' => 'integer',
];
// Relationships
public function brand(): BelongsTo
{
return $this->belongsTo(Brand::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_user_id');
}
public function events(): HasMany
{
return $this->hasMany(BannerAdEvent::class);
}
public function dailyStats(): HasMany
{
return $this->hasMany(BannerAdDailyStat::class);
}
// Scopes
public function scopeActive($query)
{
return $query->where('status', BannerAdStatus::ACTIVE)
->where(function ($q) {
$q->whereNull('starts_at')->orWhere('starts_at', '<=', now());
})
->where(function ($q) {
$q->whereNull('ends_at')->orWhere('ends_at', '>=', now());
});
}
public function scopeForZone($query, BannerAdZone|string $zone)
{
$zoneValue = $zone instanceof BannerAdZone ? $zone->value : $zone;
return $query->where('zone', $zoneValue);
}
public function scopePlatformWide($query)
{
return $query->where('is_platform_wide', true);
}
public function scopeForBusinessType($query, ?string $businessType)
{
if (! $businessType) {
return $query;
}
return $query->where(function ($q) use ($businessType) {
$q->whereNull('target_business_types')
->orWhereJsonContains('target_business_types', $businessType);
});
}
public function scopeScheduled($query)
{
return $query->where('status', BannerAdStatus::SCHEDULED)
->whereNotNull('starts_at')
->where('starts_at', '>', now());
}
public function scopeExpired($query)
{
return $query->where('status', BannerAdStatus::EXPIRED);
}
// Accessors
public function getImageUrlAttribute(): ?string
{
if (! $this->image_path) {
return null;
}
return Storage::url($this->image_path);
}
public function getClickThroughRateAttribute(): float
{
if ($this->impressions === 0) {
return 0;
}
return round(($this->clicks / $this->impressions) * 100, 2);
}
public function getIsCurrentlyActiveAttribute(): bool
{
if ($this->status !== BannerAdStatus::ACTIVE) {
return false;
}
$now = now();
if ($this->starts_at && $this->starts_at > $now) {
return false;
}
if ($this->ends_at && $this->ends_at < $now) {
return false;
}
return true;
}
public function getDimensionsAttribute(): array
{
return $this->zone?->dimensions() ?? ['width' => 728, 'height' => 90, 'display' => '728x90'];
}
// Methods
public function incrementImpressions(): void
{
$this->increment('impressions');
}
public function incrementClicks(): void
{
$this->increment('clicks');
}
/**
* Get image URL for serving via controller
*/
public function getImageUrl(?int $width = null): ?string
{
if (! $this->image_path) {
return null;
}
return route('image.banner-ad', [
'bannerAd' => $this->id,
'width' => $width,
]);
}
/**
* Get click tracking URL
*/
public function getClickUrl(): string
{
return route('banner-ad.click', $this->id);
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BannerAdDailyStat extends Model
{
protected $fillable = [
'banner_ad_id',
'date',
'impressions',
'clicks',
'unique_impressions',
'unique_clicks',
];
protected $casts = [
'date' => 'date',
'impressions' => 'integer',
'clicks' => 'integer',
'unique_impressions' => 'integer',
'unique_clicks' => 'integer',
];
// Relationships
public function bannerAd(): BelongsTo
{
return $this->belongsTo(BannerAd::class);
}
// Accessors
public function getClickThroughRateAttribute(): float
{
if ($this->impressions === 0) {
return 0;
}
return round(($this->clicks / $this->impressions) * 100, 2);
}
public function getUniqueClickThroughRateAttribute(): float
{
if ($this->unique_impressions === 0) {
return 0;
}
return round(($this->unique_clicks / $this->unique_impressions) * 100, 2);
}
// Scopes
public function scopeForDateRange($query, $startDate, $endDate)
{
return $query->whereBetween('date', [$startDate, $endDate]);
}
public function scopeForAd($query, $bannerAdId)
{
return $query->where('banner_ad_id', $bannerAdId);
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BannerAdEvent extends Model
{
public $timestamps = false;
protected $fillable = [
'banner_ad_id',
'business_id',
'user_id',
'event_type',
'session_id',
'ip_address',
'user_agent',
'page_url',
'referer',
'created_at',
];
protected $casts = [
'created_at' => 'datetime',
];
protected static function booted(): void
{
static::creating(function (self $event) {
$event->created_at = $event->created_at ?? now();
});
}
// Relationships
public function bannerAd(): BelongsTo
{
return $this->belongsTo(BannerAd::class);
}
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
// Scopes
public function scopeImpressions($query)
{
return $query->where('event_type', 'impression');
}
public function scopeClicks($query)
{
return $query->where('event_type', 'click');
}
public function scopeForDate($query, $date)
{
return $query->whereDate('created_at', $date);
}
public function scopeForDateRange($query, $startDate, $endDate)
{
return $query->whereBetween('created_at', [$startDate, $endDate]);
}
}

View File

@@ -129,6 +129,9 @@ class Brand extends Model implements Auditable
// CRM Channel for inbound emails
'inbound_email_channel_id',
// CannaIQ Integration
'cannaiq_brand_key', // Normalized brand name for CannaIQ API (e.g., "alohatymemachine")
];
protected $casts = [
@@ -325,6 +328,47 @@ class Brand extends Model implements Auditable
->get();
}
// CannaIQ Integration
/**
* Check if brand is connected to CannaIQ
*/
public function isCannaiqConnected(): bool
{
return ! empty($this->cannaiq_brand_key);
}
/**
* Normalize a brand name for CannaIQ API key
* Removes spaces, special chars, converts to lowercase
*
* Example: "Aloha TymeMachine" "alohatymemachine"
*/
public static function normalizeCannaiqKey(string $brandName): string
{
// Remove all non-alphanumeric characters and convert to lowercase
return strtolower(preg_replace('/[^a-zA-Z0-9]/', '', $brandName));
}
/**
* Connect brand to CannaIQ using brand name
* Normalizes the name and stores as cannaiq_brand_key
*/
public function connectToCannaiq(string $brandName): void
{
$this->cannaiq_brand_key = self::normalizeCannaiqKey($brandName);
$this->save();
}
/**
* Disconnect brand from CannaIQ
*/
public function disconnectFromCannaiq(): void
{
$this->cannaiq_brand_key = null;
$this->save();
}
/**
* Generate slug from name
*/

View File

@@ -293,6 +293,7 @@ class Business extends Model implements AuditableContract
'has_enterprise_suite',
'use_suite_navigation',
'cannaiq_enabled',
'ping_pong_enabled',
// Sales Suite Usage Limits
'sales_suite_brand_limit',
@@ -368,6 +369,7 @@ class Business extends Model implements AuditableContract
'is_enterprise_plan' => 'boolean', // Plan limit override - when true, usage limits are not enforced
'use_suite_navigation' => 'boolean',
'cannaiq_enabled' => 'boolean',
'ping_pong_enabled' => 'boolean',
// Sales Suite Usage Limits
'sales_suite_brand_limit' => 'integer',
'sales_suite_sku_limit_per_brand' => 'integer',
@@ -531,6 +533,47 @@ class Business extends Model implements AuditableContract
return $this->hasMany(Brand::class);
}
// =========================================================================
// DBA (Doing Business As) Relationships
// =========================================================================
/**
* Get all DBAs for this business.
*/
public function dbas(): HasMany
{
return $this->hasMany(BusinessDba::class);
}
/**
* Get active DBAs for this business.
*/
public function activeDbas(): HasMany
{
return $this->hasMany(BusinessDba::class)->where('is_active', true);
}
/**
* Get the default DBA for this business.
*/
public function defaultDba(): HasOne
{
return $this->hasOne(BusinessDba::class)->where('is_default', true);
}
/**
* Get DBA for invoice generation.
* Priority: explicit dba_id > default DBA > first active DBA > null
*/
public function getDbaForInvoice(?int $dbaId = null): ?BusinessDba
{
if ($dbaId) {
return $this->dbas()->find($dbaId);
}
return $this->defaultDba ?? $this->activeDbas()->first();
}
public function brandAiProfiles(): HasMany
{
return $this->hasMany(BrandAiProfile::class);
@@ -612,6 +655,24 @@ class Business extends Model implements AuditableContract
return $this->hasOne(BusinessSettings::class);
}
/**
* Get email identities for inbound email routing.
*/
public function emailIdentities(): HasMany
{
return $this->hasMany(BusinessEmailIdentity::class);
}
/**
* Get the primary email identity (first active one).
*/
public function primaryEmailIdentity(): HasOne
{
return $this->hasOne(BusinessEmailIdentity::class)
->where('is_active', true)
->orderBy('id');
}
public function approver()
{
return $this->belongsTo(User::class, 'approved_by');

250
app/Models/BusinessDba.php Normal file
View File

@@ -0,0 +1,250 @@
<?php
namespace App\Models;
use App\Traits\BelongsToBusinessDirectly;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
use OwenIt\Auditing\Contracts\Auditable;
class BusinessDba extends Model implements Auditable
{
use BelongsToBusinessDirectly;
use HasFactory;
use \OwenIt\Auditing\Auditable;
use SoftDeletes;
protected $table = 'business_dbas';
protected $fillable = [
'business_id',
'trade_name',
'slug',
// Address
'address',
'address_line_2',
'city',
'state',
'zip',
// License
'license_number',
'license_type',
'license_expiration',
// Bank Info
'bank_name',
'bank_account_name',
'bank_routing_number',
'bank_account_number',
'bank_account_type',
// Tax
'tax_id',
'tax_id_type',
// Contacts
'primary_contact_name',
'primary_contact_email',
'primary_contact_phone',
'ap_contact_name',
'ap_contact_email',
'ap_contact_phone',
// Invoice Settings
'payment_terms',
'payment_instructions',
'invoice_footer',
'invoice_prefix',
// Branding
'logo_path',
'brand_colors',
// Status
'is_default',
'is_active',
];
protected $casts = [
'brand_colors' => 'array',
'is_default' => 'boolean',
'is_active' => 'boolean',
'license_expiration' => 'date',
// Encrypted fields
'bank_routing_number' => 'encrypted',
'bank_account_number' => 'encrypted',
'tax_id' => 'encrypted',
];
/**
* Fields to exclude from audit logging (sensitive data)
*/
protected array $auditExclude = [
'bank_routing_number',
'bank_account_number',
'tax_id',
];
// =========================================================================
// Relationships
// =========================================================================
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function invoices(): HasMany
{
return $this->hasMany(\App\Models\Crm\CrmInvoice::class, 'dba_id');
}
public function orders(): HasMany
{
return $this->hasMany(Order::class, 'seller_dba_id');
}
// =========================================================================
// Scopes
// =========================================================================
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopeDefault($query)
{
return $query->where('is_default', true);
}
public function scopeForBusiness($query, int $businessId)
{
return $query->where('business_id', $businessId);
}
// =========================================================================
// Accessors
// =========================================================================
/**
* Get the full formatted address.
*/
public function getFullAddressAttribute(): string
{
$parts = array_filter([
$this->address,
$this->address_line_2,
]);
$cityStateZip = trim(
($this->city ?? '').
($this->city && $this->state ? ', ' : '').
($this->state ?? '').' '.
($this->zip ?? '')
);
if ($cityStateZip) {
$parts[] = $cityStateZip;
}
return implode("\n", $parts);
}
/**
* Get masked bank account number (last 4 digits).
*/
public function getMaskedAccountNumberAttribute(): ?string
{
if (! $this->bank_account_number) {
return null;
}
return '****'.substr($this->bank_account_number, -4);
}
/**
* Get masked tax ID (last 4 digits).
*/
public function getMaskedTaxIdAttribute(): ?string
{
if (! $this->tax_id) {
return null;
}
return '***-**-'.substr($this->tax_id, -4);
}
// =========================================================================
// Methods
// =========================================================================
/**
* Mark this DBA as the default for the business.
*/
public function markAsDefault(): void
{
// Clear default from other DBAs for this business
static::where('business_id', $this->business_id)
->where('id', '!=', $this->id)
->update(['is_default' => false]);
$this->update(['is_default' => true]);
}
/**
* Get display info for invoices/orders.
*/
public function getDisplayInfo(): array
{
return [
'name' => $this->trade_name,
'address' => $this->full_address,
'license' => $this->license_number,
'logo' => $this->logo_path,
'payment_terms' => $this->payment_terms,
'payment_instructions' => $this->payment_instructions,
'invoice_footer' => $this->invoice_footer,
'primary_contact' => [
'name' => $this->primary_contact_name,
'email' => $this->primary_contact_email,
'phone' => $this->primary_contact_phone,
],
'ap_contact' => [
'name' => $this->ap_contact_name,
'email' => $this->ap_contact_email,
'phone' => $this->ap_contact_phone,
],
];
}
// =========================================================================
// Boot
// =========================================================================
protected static function boot()
{
parent::boot();
// Auto-generate slug on creation
static::creating(function ($dba) {
if (empty($dba->slug)) {
$dba->slug = Str::slug($dba->trade_name);
// Ensure unique
$original = $dba->slug;
$counter = 1;
while (static::withTrashed()->where('slug', $dba->slug)->exists()) {
$dba->slug = $original.'-'.$counter++;
}
}
});
// Ensure only one default per business
static::saving(function ($dba) {
if ($dba->is_default && $dba->isDirty('is_default')) {
static::where('business_id', $dba->business_id)
->where('id', '!=', $dba->id ?? 0)
->update(['is_default' => false]);
}
});
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Models\Crm;
use App\Models\Accounting\ArInvoice;
use App\Models\Activity;
use App\Models\Business;
use App\Models\BusinessDba;
use App\Models\BusinessLocation;
use App\Models\Contact;
use App\Models\Order;
@@ -55,6 +56,7 @@ class CrmInvoice extends Model
protected $fillable = [
'business_id',
'dba_id',
'account_id',
'location_id',
'contact_id',
@@ -110,6 +112,14 @@ class CrmInvoice extends Model
return $this->belongsTo(Business::class);
}
/**
* The DBA (trade name) used for this invoice.
*/
public function dba(): BelongsTo
{
return $this->belongsTo(BusinessDba::class, 'dba_id');
}
public function account(): BelongsTo
{
return $this->belongsTo(Business::class, 'account_id');
@@ -400,4 +410,45 @@ class CrmInvoice extends Model
return $prefix.str_pad($number, 5, '0', STR_PAD_LEFT);
}
/**
* Get seller display information for the invoice.
* Prioritizes DBA if set, otherwise falls back to business defaults.
*/
public function getSellerDisplayInfo(): array
{
if ($this->dba_id && $this->dba) {
return $this->dba->getDisplayInfo();
}
// Fall back to business info
$business = $this->business;
return [
'name' => $business->dba_name ?: $business->name,
'address' => implode("\n", array_filter([
$business->invoice_payable_address ?? $business->physical_address,
trim(
($business->invoice_payable_city ?? $business->physical_city ?? '').
($business->invoice_payable_state ?? $business->physical_state ? ', '.($business->invoice_payable_state ?? $business->physical_state) : '').' '.
($business->invoice_payable_zipcode ?? $business->physical_zipcode ?? '')
),
])),
'license' => $business->license_number,
'logo' => null,
'payment_terms' => null,
'payment_instructions' => $business->order_invoice_footer,
'invoice_footer' => $business->order_invoice_footer,
'primary_contact' => [
'name' => trim(($business->primary_contact_first_name ?? '').' '.($business->primary_contact_last_name ?? '')),
'email' => $business->primary_contact_email ?? $business->business_email,
'phone' => $business->primary_contact_phone ?? $business->business_phone,
],
'ap_contact' => [
'name' => trim(($business->ap_contact_first_name ?? '').' '.($business->ap_contact_last_name ?? '')),
'email' => $business->ap_contact_email,
'phone' => $business->ap_contact_phone,
],
];
}
}

View File

@@ -34,6 +34,7 @@ class CrmInvoiceItem extends Model
'tax_rate',
'tax_amount',
'line_total',
'item_comment',
];
protected $casts = [
@@ -54,11 +55,18 @@ class CrmInvoiceItem extends Model
});
static::saved(function ($item) {
$item->invoice->calculateTotals();
// Use relationLoaded to prevent lazy loading violation
// The invoice totals are recalculated explicitly in the controller after all items are saved
if ($item->relationLoaded('invoice')) {
$item->invoice->calculateTotals();
}
});
static::deleted(function ($item) {
$item->invoice->calculateTotals();
// Use relationLoaded to prevent lazy loading violation
if ($item->relationLoaded('invoice')) {
$item->invoice->calculateTotals();
}
});
}

View File

@@ -28,6 +28,7 @@ class CrmQuoteItem extends Model
'tax_rate',
'line_total',
'sort_order',
'item_comment',
];
protected $casts = [
@@ -46,11 +47,18 @@ class CrmQuoteItem extends Model
});
static::saved(function ($item) {
$item->quote->calculateTotals();
// Use withoutRelations to prevent lazy loading violation
// The quote totals are recalculated explicitly in the controller after all items are saved
if ($item->relationLoaded('quote')) {
$item->quote->calculateTotals();
}
});
static::deleted(function ($item) {
$item->quote->calculateTotals();
// Use withoutRelations to prevent lazy loading violation
if ($item->relationLoaded('quote')) {
$item->quote->calculateTotals();
}
});
}

View File

@@ -9,6 +9,7 @@ use App\Models\Contact;
use App\Models\Conversation;
use App\Models\MarketplaceChatParticipant;
use App\Models\Order;
use App\Models\SalesRepAssignment;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -103,6 +104,10 @@ class CrmThread extends Model
'snoozed_until' => 'datetime',
'first_response_at' => 'datetime',
'currently_viewing_since' => 'datetime',
'is_chat_request' => 'boolean',
'chat_request_at' => 'datetime',
'chat_request_responded_at' => 'datetime',
'buyer_context' => 'array',
];
protected $appends = ['is_snoozed', 'other_viewers'];
@@ -294,6 +299,44 @@ class CrmThread extends Model
->where('snoozed_until', '<=', now());
}
/**
* Scope to filter threads for a sales rep.
*
* Shows threads where:
* - The account (buyer business) is assigned to this sales rep, OR
* - The thread is directly assigned to this user
*
* @param int $businessId The seller business ID
* @param int $userId The sales rep user ID
*/
public function scopeForSalesRep($query, int $businessId, int $userId)
{
// Get account IDs assigned to this sales rep
$assignedAccountIds = SalesRepAssignment::where('business_id', $businessId)
->where('user_id', $userId)
->where('assignable_type', Business::class)
->pluck('assignable_id');
return $query->where(function ($q) use ($assignedAccountIds, $userId) {
// Threads for assigned accounts
$q->whereIn('account_id', $assignedAccountIds)
// OR threads directly assigned to this user
->orWhere('assigned_to', $userId);
});
}
/**
* Scope to filter threads for a brand portal user.
*
* Shows only threads related to brands the user has access to.
*
* @param array $brandIds Brand IDs the user can access
*/
public function scopeForBrandPortal($query, array $brandIds)
{
return $query->whereIn('brand_id', $brandIds);
}
// Accessors
public function getIsSnoozedAttribute(): bool

View File

@@ -44,6 +44,7 @@ class Order extends Model implements Auditable
'tax',
'total',
'status',
'is_ping_pong',
'created_by',
'workorder_status',
'payment_terms',
@@ -98,6 +99,7 @@ class Order extends Model implements Auditable
'surcharge' => 'decimal:2',
'tax' => 'decimal:2',
'total' => 'decimal:2',
'is_ping_pong' => 'boolean',
'workorder_status' => 'decimal:2',
'due_date' => 'date',
'delivery_window_date' => 'date',

View File

@@ -31,6 +31,7 @@ class OrderItem extends Model implements Auditable
'product_name',
'product_sku',
'brand_name',
'item_comment',
];
/**

View File

@@ -265,6 +265,14 @@ class Product extends Model implements Auditable
return $this->belongsTo(Brand::class);
}
/**
* CannaiQ product mappings for this product.
*/
public function cannaiqMappings(): HasMany
{
return $this->hasMany(ProductCannaiqMapping::class);
}
public function category(): BelongsTo
{
return $this->belongsTo(ProductCategory::class, 'category_id');

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Maps Hub products to CannaiQ products.
*
* Many-to-many relationship:
* - One Hub product can map to multiple CannaiQ products
* - One CannaiQ product can map to multiple Hub products
*/
class ProductCannaiqMapping extends Model
{
protected $fillable = [
'product_id',
'cannaiq_product_id',
'cannaiq_product_name',
'cannaiq_store_id',
'cannaiq_store_name',
];
protected $casts = [
'cannaiq_product_id' => 'integer',
];
/**
* The Hub product this mapping belongs to.
*/
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
}

View File

@@ -0,0 +1,194 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class TeamConversation extends Model
{
use HasFactory, SoftDeletes;
public const TYPE_DIRECT = 'direct';
public const TYPE_GROUP = 'group';
protected $fillable = [
'business_id',
'type',
'name',
'last_message_preview',
'last_message_at',
'created_by',
];
protected $casts = [
'last_message_at' => 'datetime',
];
// Relationships
public function business(): BelongsTo
{
return $this->belongsTo(Business::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function participants(): BelongsToMany
{
return $this->belongsToMany(User::class, 'team_conversation_participants', 'conversation_id', 'user_id')
->withPivot(['is_admin', 'last_read_at', 'is_muted', 'is_pinned'])
->withTimestamps();
}
public function messages(): HasMany
{
return $this->hasMany(TeamMessage::class, 'conversation_id');
}
// Scopes
public function scopeForUser($query, int $userId)
{
return $query->whereHas('participants', fn ($q) => $q->where('user_id', $userId));
}
public function scopeForBusiness($query, int $businessId)
{
return $query->where('business_id', $businessId);
}
// Helper Methods
/**
* Get or create a direct conversation between two users
*/
public static function getOrCreateDirect(int $businessId, int $userId1, int $userId2): self
{
// Find existing direct conversation between these two users
$conversation = self::where('business_id', $businessId)
->where('type', self::TYPE_DIRECT)
->whereHas('participants', fn ($q) => $q->where('user_id', $userId1))
->whereHas('participants', fn ($q) => $q->where('user_id', $userId2))
->first();
if ($conversation) {
return $conversation;
}
// Create new direct conversation
$conversation = self::create([
'business_id' => $businessId,
'type' => self::TYPE_DIRECT,
'created_by' => $userId1,
]);
// Add both participants
$conversation->participants()->attach([
$userId1 => ['is_admin' => false],
$userId2 => ['is_admin' => false],
]);
return $conversation;
}
/**
* Get the other participant in a direct conversation
*/
public function getOtherParticipant(int $currentUserId): ?User
{
if ($this->type !== self::TYPE_DIRECT) {
return null;
}
return $this->participants->firstWhere('id', '!=', $currentUserId);
}
/**
* Get display name for the conversation
*/
public function getDisplayName(int $currentUserId): string
{
if ($this->type === self::TYPE_GROUP) {
return $this->name ?? 'Group Chat';
}
$other = $this->getOtherParticipant($currentUserId);
return $other ? $other->name : 'Unknown';
}
/**
* Check if user has unread messages
*/
public function hasUnreadFor(int $userId): bool
{
$participant = $this->participants->firstWhere('id', $userId);
if (! $participant) {
return false;
}
$lastRead = $participant->pivot->last_read_at;
if (! $lastRead) {
return $this->messages()->exists();
}
return $this->messages()
->where('created_at', '>', $lastRead)
->where('sender_id', '!=', $userId)
->exists();
}
/**
* Get unread count for user
*/
public function getUnreadCountFor(int $userId): int
{
$participant = $this->participants->firstWhere('id', $userId);
if (! $participant) {
return 0;
}
$lastRead = $participant->pivot->last_read_at;
$query = $this->messages()->where('sender_id', '!=', $userId);
if ($lastRead) {
$query->where('created_at', '>', $lastRead);
}
return $query->count();
}
/**
* Mark conversation as read for user
*/
public function markReadFor(int $userId): void
{
$this->participants()->updateExistingPivot($userId, [
'last_read_at' => now(),
]);
}
/**
* Update last message info
*/
public function updateLastMessage(TeamMessage $message): void
{
$this->update([
'last_message_preview' => \Str::limit($message->body, 100),
'last_message_at' => $message->created_at,
]);
}
}

107
app/Models/TeamMessage.php Normal file
View File

@@ -0,0 +1,107 @@
<?php
namespace App\Models;
use App\Events\TeamMessageSent;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class TeamMessage extends Model
{
use HasFactory, SoftDeletes;
public const TYPE_TEXT = 'text';
public const TYPE_FILE = 'file';
public const TYPE_IMAGE = 'image';
public const TYPE_SYSTEM = 'system';
protected $fillable = [
'conversation_id',
'sender_id',
'body',
'type',
'metadata',
'read_by',
];
protected $casts = [
'metadata' => 'array',
'read_by' => 'array',
];
// Relationships
public function conversation(): BelongsTo
{
return $this->belongsTo(TeamConversation::class, 'conversation_id');
}
public function sender(): BelongsTo
{
return $this->belongsTo(User::class, 'sender_id');
}
// Events
protected static function booted(): void
{
static::created(function (TeamMessage $message) {
// Update conversation's last message
$message->conversation->updateLastMessage($message);
// Broadcast to other participants
broadcast(new TeamMessageSent($message))->toOthers();
});
}
// Helper Methods
/**
* Mark message as read by user
*/
public function markReadBy(int $userId): void
{
$readBy = $this->read_by ?? [];
if (! in_array($userId, $readBy)) {
$readBy[] = $userId;
$this->update(['read_by' => $readBy]);
}
}
/**
* Check if message has been read by user
*/
public function isReadBy(int $userId): bool
{
return in_array($userId, $this->read_by ?? []);
}
/**
* Get sender's display name
*/
public function getSenderName(): string
{
return $this->sender?->name ?? 'Unknown';
}
/**
* Get sender's initials
*/
public function getSenderInitials(): string
{
if (! $this->sender) {
return '?';
}
$firstName = $this->sender->first_name ?? '';
$lastName = $this->sender->last_name ?? '';
return strtoupper(substr($firstName, 0, 1).substr($lastName, 0, 1)) ?: '?';
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Notifications;
use App\Models\Crm\CrmChannelMessage;
use App\Models\Crm\CrmThread;
use App\Services\NotificationStyleService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
class CrmNewMessageNotification extends Notification implements ShouldQueue
{
use Queueable;
/**
* Create a new notification instance.
*/
public function __construct(
protected CrmChannelMessage $message,
protected CrmThread $thread
) {}
/**
* Get the notification's delivery channels.
*
* @return array<int, string>
*/
public function via(object $notifiable): array
{
// Database only - no email for each message
return ['database'];
}
/**
* Get the array representation of the notification.
*
* @return array<string, mixed>
*/
public function toArray(object $notifiable): array
{
$business = $this->thread->business;
$businessSlug = $business?->slug ?? 'default';
$contact = $this->thread->contact;
$contactName = $contact?->getFullName() ?? 'Unknown';
$channelType = $this->message->channel_type;
$preview = \Illuminate\Support\Str::limit($this->message->body, 50);
$style = NotificationStyleService::getStyle('message');
return [
'type' => 'crm_message',
'title' => 'New Message',
'message' => "{$contactName} via {$channelType}: {$preview}",
'action_url' => route('seller.business.crm.inbox', $businessSlug),
'action_text' => 'View Inbox',
'icon' => $style['icon'],
'color' => $style['color'],
'meta' => [
'thread_id' => $this->thread->id,
'message_id' => $this->message->id,
'channel_type' => $channelType,
'contact_id' => $contact?->id,
'contact_name' => $contactName,
],
];
}
}

View File

@@ -0,0 +1,164 @@
<?php
namespace App\Services;
use App\Enums\BannerAdStatus;
use App\Enums\BannerAdZone;
use App\Models\BannerAd;
use App\Models\BannerAdEvent;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Cache;
class BannerAdService
{
/**
* Get active ads for a zone, optionally filtered by user context
*/
public function getAdsForZone(
BannerAdZone $zone,
?string $businessType = null,
int $limit = 10
): Collection {
$cacheKey = "banner_ads:{$zone->value}:".($businessType ?? 'all');
return Cache::remember($cacheKey, 300, function () use ($zone, $businessType) {
$query = BannerAd::active()
->forZone($zone)
->forBusinessType($businessType)
->orderByDesc('priority')
->orderByDesc('weight');
return $query->get();
})->take($limit);
}
/**
* Get a single ad for display with weighted random selection
*/
public function getAdForZone(
BannerAdZone $zone,
?string $businessType = null
): ?BannerAd {
$ads = $this->getAdsForZone($zone, $businessType, 10);
if ($ads->isEmpty()) {
return null;
}
// If only one ad, return it
if ($ads->count() === 1) {
return $ads->first();
}
// Weighted random selection
$totalWeight = $ads->sum('weight');
$random = rand(1, $totalWeight);
$currentWeight = 0;
foreach ($ads as $ad) {
$currentWeight += $ad->weight;
if ($random <= $currentWeight) {
return $ad;
}
}
return $ads->first();
}
/**
* Record an impression event (fire-and-forget)
*/
public function recordImpression(BannerAd $ad, array $context = []): void
{
// Fire-and-forget to avoid blocking page load
dispatch(function () use ($ad, $context) {
BannerAdEvent::create([
'banner_ad_id' => $ad->id,
'business_id' => $context['business_id'] ?? null,
'user_id' => $context['user_id'] ?? null,
'event_type' => 'impression',
'session_id' => $context['session_id'] ?? session()->getId(),
'ip_address' => $context['ip_address'] ?? request()->ip(),
'user_agent' => $context['user_agent'] ?? request()->userAgent(),
'page_url' => $context['page_url'] ?? request()->fullUrl(),
'referer' => $context['referer'] ?? request()->header('referer'),
]);
$ad->incrementImpressions();
})->afterResponse();
}
/**
* Record a click event
*/
public function recordClick(BannerAd $ad, array $context = []): void
{
BannerAdEvent::create([
'banner_ad_id' => $ad->id,
'business_id' => $context['business_id'] ?? null,
'user_id' => $context['user_id'] ?? null,
'event_type' => 'click',
'session_id' => $context['session_id'] ?? session()->getId(),
'ip_address' => $context['ip_address'] ?? request()->ip(),
'user_agent' => $context['user_agent'] ?? request()->userAgent(),
'page_url' => $context['page_url'] ?? null,
'referer' => $context['referer'] ?? request()->header('referer'),
]);
$ad->incrementClicks();
}
/**
* Clear cache for a zone
*/
public function clearZoneCache(BannerAdZone $zone): void
{
Cache::forget("banner_ads:{$zone->value}:all");
Cache::forget("banner_ads:{$zone->value}:buyer");
Cache::forget("banner_ads:{$zone->value}:seller");
Cache::forget("banner_ads:{$zone->value}:both");
}
/**
* Clear all banner ad caches
*/
public function clearAllCaches(): void
{
foreach (BannerAdZone::cases() as $zone) {
$this->clearZoneCache($zone);
}
}
/**
* Update ad statuses based on schedule
*
* @return int Number of ads updated
*/
public function updateScheduledStatuses(): int
{
$now = now();
$updated = 0;
// Activate scheduled ads that have started
$updated += BannerAd::where('status', BannerAdStatus::SCHEDULED)
->whereNotNull('starts_at')
->where('starts_at', '<=', $now)
->where(function ($q) use ($now) {
$q->whereNull('ends_at')->orWhere('ends_at', '>', $now);
})
->update(['status' => BannerAdStatus::ACTIVE]);
// Expire active ads that have ended
$updated += BannerAd::where('status', BannerAdStatus::ACTIVE)
->whereNotNull('ends_at')
->where('ends_at', '<', $now)
->update(['status' => BannerAdStatus::EXPIRED]);
// Clear caches if any ads were updated
if ($updated > 0) {
$this->clearAllCaches();
}
return $updated;
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Services\Crm;
use App\Events\CrmThreadMessageSent;
use App\Jobs\Crm\SendChannelMessageJob;
use App\Models\Contact;
use App\Models\Crm\CrmChannel;
@@ -120,6 +121,12 @@ class CrmChannelService
// Update thread
$thread->updateLastMessage($message);
// Broadcast incoming message for real-time updates
broadcast(new CrmThreadMessageSent($message->fresh(['attachments', 'user']), $thread));
// Send notification to assigned user or business users
$this->notifyUsersOfNewMessage($message, $thread);
// Trigger automations
app(CrmAutomationService::class)->trigger('message_received', [
'business_id' => $businessId,
@@ -227,7 +234,10 @@ class CrmChannelService
$thread->update(['status' => CrmThread::STATUS_OPEN]);
}
// 7) Trigger automations
// 7) Broadcast incoming message for real-time updates
broadcast(new CrmThreadMessageSent($message->fresh(['attachments', 'user']), $thread));
// 8) Trigger automations
app(CrmAutomationService::class)->trigger('message_received', [
'business_id' => $businessId,
'message' => $message,
@@ -371,6 +381,9 @@ class CrmChannelService
if ($message->thread) {
$message->thread->updateLastMessage($message);
$message->thread->recordFirstResponse();
// Broadcast the new message for real-time updates
broadcast(new CrmThreadMessageSent($message->fresh(['attachments', 'user']), $message->thread))->toOthers();
}
return true;
@@ -757,4 +770,31 @@ class CrmChannelService
default => 'crm',
};
}
/**
* Notify users of a new inbound message
*/
protected function notifyUsersOfNewMessage(CrmChannelMessage $message, CrmThread $thread): void
{
$notification = new \App\Notifications\CrmNewMessageNotification($message, $thread);
// If thread is assigned to a specific user, notify them
if ($thread->assigned_to) {
$assignee = \App\Models\User::find($thread->assigned_to);
if ($assignee) {
$assignee->notify($notification);
return;
}
}
// Otherwise notify all business users (up to 5 to avoid spam)
$business = $thread->business;
if ($business) {
$users = $business->users()->limit(5)->get();
foreach ($users as $user) {
$user->notify($notification);
}
}
}
}

View File

@@ -50,11 +50,17 @@ class InboundEmailService
return null;
}
// Parse plus addressing (inbox+u123@domain.com => user ID 123)
$assignToUserId = $this->parseUserFromPlusAddress($toEmail);
// Strip plus address for identity lookup
$baseEmail = $this->stripPlusAddress($toEmail);
// 1) Resolve business + email identity
$identity = BusinessEmailIdentity::findByEmail($toEmail);
$identity = BusinessEmailIdentity::findByEmail($baseEmail);
if (! $identity) {
Log::info("InboundEmailService: No identity found for {$toEmail}");
Log::info("InboundEmailService: No identity found for {$baseEmail}");
return null;
}
@@ -77,13 +83,26 @@ class InboundEmailService
// 4) Find or create thread (using In-Reply-To / Message-ID / contact)
$thread = $this->findOrCreateThread($business, $channel, $contact, $payload);
// 5) Store the inbound message
// 5) Auto-assign thread if plus address specified a user
if ($assignToUserId && ! $thread->assigned_to) {
// Verify user belongs to this business
$user = \App\Models\User::find($assignToUserId);
if ($user && $user->businesses()->where('businesses.id', $business->id)->exists()) {
$thread->update(['assigned_to' => $assignToUserId]);
Log::info("InboundEmailService: Auto-assigned thread {$thread->id} to user {$assignToUserId} via plus address");
}
}
// 6) Store the inbound message
$message = $this->storeInboundMessage($thread, $channel, $contact, $payload);
// 6) Update identity last received timestamp
// 7) Update identity last received timestamp
$identity->recordReceived();
// 7) Trigger automations
// 8) Send notification to users
$this->notifyUsersOfNewMessage($message, $thread);
// 9) Trigger automations
app(CrmAutomationService::class)->trigger('message_received', [
'business_id' => $business->id,
'message' => $message,
@@ -97,6 +116,33 @@ class InboundEmailService
return $message;
}
/**
* Parse user ID from plus address.
* inbox+u123@domain.com => 123
*/
protected function parseUserFromPlusAddress(string $email): ?int
{
if (! str_contains($email, '+')) {
return null;
}
// Extract the part between + and @
if (preg_match('/\+u(\d+)@/', $email, $matches)) {
return (int) $matches[1];
}
return null;
}
/**
* Strip plus address from email.
* inbox+u123@domain.com => inbox@domain.com
*/
protected function stripPlusAddress(string $email): string
{
return preg_replace('/\+[^@]+@/', '@', $email);
}
/**
* Find or create a contact from the sender's email.
*/
@@ -338,4 +384,31 @@ class InboundEmailService
return $message;
}
/**
* Notify users of a new inbound email message.
*/
protected function notifyUsersOfNewMessage(CrmChannelMessage $message, CrmThread $thread): void
{
$notification = new \App\Notifications\CrmNewMessageNotification($message, $thread);
// If thread is assigned to a specific user, notify them
if ($thread->assigned_to) {
$assignee = \App\Models\User::find($thread->assigned_to);
if ($assignee) {
$assignee->notify($notification);
return;
}
}
// Otherwise notify all business users (up to 5 to avoid spam)
$business = $thread->business;
if ($business) {
$users = $business->users()->limit(5)->get();
foreach ($users as $user) {
$user->notify($notification);
}
}
}
}

View File

@@ -16,6 +16,12 @@ class NotificationStyleService
'icon' => 'bell',
],
// Messages - Info Blue
'message', 'chat', 'inbox' => [
'color' => 'primary',
'icon' => 'message-circle',
],
// Success - Green
'success', 'approved', 'completed', 'achievement' => [
'color' => 'success',

View File

@@ -53,7 +53,7 @@ class SuiteMenuResolver
'connect_conversations' => [
'label' => 'Conversations',
'icon' => 'heroicon-o-chat-bubble-left-right',
'route' => 'seller.business.crm.threads.index',
'route' => 'seller.business.crm.inbox',
'section' => 'Connect',
'order' => 20,
],
@@ -143,7 +143,7 @@ class SuiteMenuResolver
'crm_inbox' => [
'label' => 'Conversations',
'icon' => 'heroicon-o-inbox-stack',
'route' => 'seller.business.crm.threads.index',
'route' => 'seller.business.crm.inbox',
'section' => 'Connect',
'order' => 22,
],

View File

@@ -0,0 +1,51 @@
<?php
namespace App\View\Components;
use App\Enums\BannerAdZone;
use App\Models\BannerAd as BannerAdModel;
use App\Services\BannerAdService;
use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;
class BannerAd extends Component
{
public ?BannerAdModel $ad = null;
public BannerAdZone $zone;
public array $dimensions;
public function __construct(
string $zone,
) {
$this->zone = BannerAdZone::from($zone);
$this->dimensions = $this->zone->dimensions();
// Get business type from authenticated user
$businessType = auth()->user()?->user_type;
// Get ad from service
$service = app(BannerAdService::class);
$this->ad = $service->getAdForZone($this->zone, $businessType);
// Record impression if ad found
if ($this->ad) {
$service->recordImpression($this->ad, [
'business_id' => auth()->user()?->businesses->first()?->id,
'user_id' => auth()->id(),
]);
}
}
public function shouldRender(): bool
{
return $this->ad !== null;
}
public function render(): View|Closure|string
{
return view('components.banner-ad');
}
}

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* Fix: last_name should be nullable to match controller validation
*/
public function up(): void
{
Schema::table('contacts', function (Blueprint $table) {
$table->string('last_name')->nullable()->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('contacts', function (Blueprint $table) {
$table->string('last_name')->nullable(false)->change();
});
}
};

View File

@@ -0,0 +1,83 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// Team conversations (1:1 or group chats between coworkers)
Schema::create('team_conversations', function (Blueprint $table) {
$table->id();
$table->foreignId('business_id')->constrained()->onDelete('cascade');
$table->string('type')->default('direct'); // direct, group
$table->string('name')->nullable(); // For group chats
$table->text('last_message_preview')->nullable();
$table->timestamp('last_message_at')->nullable();
$table->foreignId('created_by')->constrained('users')->onDelete('cascade');
$table->timestamps();
$table->softDeletes();
$table->index(['business_id', 'last_message_at']);
});
// Participants in team conversations
Schema::create('team_conversation_participants', function (Blueprint $table) {
$table->id();
$table->foreignId('conversation_id')->constrained('team_conversations')->onDelete('cascade');
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->boolean('is_admin')->default(false); // For group chats
$table->timestamp('last_read_at')->nullable();
$table->boolean('is_muted')->default(false);
$table->boolean('is_pinned')->default(false);
$table->timestamps();
$table->unique(['conversation_id', 'user_id']);
$table->index(['user_id', 'last_read_at']);
});
// Messages in team conversations
Schema::create('team_messages', function (Blueprint $table) {
$table->id();
$table->foreignId('conversation_id')->constrained('team_conversations')->onDelete('cascade');
$table->foreignId('sender_id')->constrained('users')->onDelete('cascade');
$table->text('body');
$table->string('type')->default('text'); // text, file, image, system
$table->json('metadata')->nullable(); // For attachments, reactions, etc.
$table->json('read_by')->nullable(); // Array of user_ids who have read
$table->timestamps();
$table->softDeletes();
$table->index(['conversation_id', 'created_at']);
});
// Add chat_request fields to crm_threads for buyer-initiated chats
Schema::table('crm_threads', function (Blueprint $table) {
$table->boolean('is_chat_request')->default(false)->after('thread_type');
$table->string('chat_request_status')->nullable()->after('is_chat_request'); // pending, accepted, declined
$table->timestamp('chat_request_at')->nullable()->after('chat_request_status');
$table->foreignId('chat_request_accepted_by')->nullable()->after('chat_request_at')->constrained('users')->nullOnDelete();
$table->timestamp('chat_request_responded_at')->nullable()->after('chat_request_accepted_by');
});
}
public function down(): void
{
Schema::table('crm_threads', function (Blueprint $table) {
$table->dropForeign(['chat_request_accepted_by']);
$table->dropColumn([
'is_chat_request',
'chat_request_status',
'chat_request_at',
'chat_request_accepted_by',
'chat_request_responded_at',
]);
});
Schema::dropIfExists('team_messages');
Schema::dropIfExists('team_conversation_participants');
Schema::dropIfExists('team_conversations');
}
};

View File

@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('crm_threads', function (Blueprint $table) {
// Buyer context - captured when chat is initiated
$table->json('buyer_context')->nullable()->after('chat_request_responded_at');
// Stores: current_page, current_product_id, cart_items, recent_products, referrer, etc.
});
}
public function down(): void
{
Schema::table('crm_threads', function (Blueprint $table) {
$table->dropColumn('buyer_context');
});
}
};

View File

@@ -0,0 +1,84 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('business_dbas', function (Blueprint $table) {
$table->id();
$table->foreignId('business_id')->constrained('businesses')->onDelete('cascade');
// Identity
$table->string('trade_name');
$table->string('slug')->unique();
// Address
$table->string('address')->nullable();
$table->string('address_line_2')->nullable();
$table->string('city')->nullable();
$table->string('state', 2)->nullable();
$table->string('zip', 10)->nullable();
// License
$table->string('license_number')->nullable();
$table->string('license_type')->nullable();
$table->date('license_expiration')->nullable();
// Bank Info (encrypted at model level)
$table->string('bank_name')->nullable();
$table->string('bank_account_name')->nullable();
$table->text('bank_routing_number')->nullable();
$table->text('bank_account_number')->nullable();
$table->string('bank_account_type', 50)->nullable();
// Tax
$table->text('tax_id')->nullable();
$table->string('tax_id_type', 50)->nullable();
// Contacts
$table->string('primary_contact_name')->nullable();
$table->string('primary_contact_email')->nullable();
$table->string('primary_contact_phone', 50)->nullable();
$table->string('ap_contact_name')->nullable();
$table->string('ap_contact_email')->nullable();
$table->string('ap_contact_phone', 50)->nullable();
// Invoice Settings
$table->string('payment_terms', 50)->nullable();
$table->text('payment_instructions')->nullable();
$table->text('invoice_footer')->nullable();
$table->string('invoice_prefix', 10)->nullable();
// Branding
$table->string('logo_path')->nullable();
$table->jsonb('brand_colors')->nullable();
// Status
$table->boolean('is_default')->default(false);
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->softDeletes();
// Indexes
$table->index('business_id');
$table->index(['business_id', 'is_default']);
$table->index('is_active');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('business_dbas');
}
};

View File

@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('crm_invoices', function (Blueprint $table) {
$table->foreignId('dba_id')
->nullable()
->after('business_id')
->constrained('business_dbas')
->onDelete('set null');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('crm_invoices', function (Blueprint $table) {
$table->dropForeign(['dba_id']);
$table->dropColumn('dba_id');
});
}
};

View File

@@ -0,0 +1,53 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// Add item_comment to invoice line items
if (Schema::hasTable('crm_invoice_items') && !Schema::hasColumn('crm_invoice_items', 'item_comment')) {
Schema::table('crm_invoice_items', function (Blueprint $table) {
$table->text('item_comment')->nullable()->after('discount_percent');
});
}
// Add item_comment to quote line items
if (Schema::hasTable('crm_quote_items') && !Schema::hasColumn('crm_quote_items', 'item_comment')) {
Schema::table('crm_quote_items', function (Blueprint $table) {
$table->text('item_comment')->nullable()->after('discount_percent');
});
}
// Add item_comment to order items (if not exists)
if (Schema::hasTable('order_items') && !Schema::hasColumn('order_items', 'item_comment')) {
Schema::table('order_items', function (Blueprint $table) {
$table->text('item_comment')->nullable()->after('notes');
});
}
}
public function down(): void
{
if (Schema::hasColumn('crm_invoice_items', 'item_comment')) {
Schema::table('crm_invoice_items', function (Blueprint $table) {
$table->dropColumn('item_comment');
});
}
if (Schema::hasColumn('crm_quote_items', 'item_comment')) {
Schema::table('crm_quote_items', function (Blueprint $table) {
$table->dropColumn('item_comment');
});
}
if (Schema::hasColumn('order_items', 'item_comment')) {
Schema::table('order_items', function (Blueprint $table) {
$table->dropColumn('item_comment');
});
}
}
};

View File

@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('businesses', function (Blueprint $table) {
$table->boolean('ping_pong_enabled')->default(false)->after('is_enterprise_plan');
});
}
public function down(): void
{
Schema::table('businesses', function (Blueprint $table) {
$table->dropColumn('ping_pong_enabled');
});
}
};

View File

@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->boolean('is_ping_pong')->default(false)->after('status');
});
}
public function down(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->dropColumn('is_ping_pong');
});
}
};

View File

@@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Fix crm_invoices and crm_invoice_items schema issues.
*
* Issues:
* 1. crm_invoices: missing location_id column (controller tries to insert it)
* 2. crm_invoice_items: 'name' column is NOT NULL but controller doesn't provide it
*/
return new class extends Migration
{
public function up(): void
{
// Add location_id to crm_invoices if it doesn't exist
if (! Schema::hasColumn('crm_invoices', 'location_id')) {
Schema::table('crm_invoices', function (Blueprint $table) {
$table->foreignId('location_id')->nullable()->after('account_id')->constrained('locations')->nullOnDelete();
});
}
// Make name nullable in crm_invoice_items
Schema::table('crm_invoice_items', function (Blueprint $table) {
$table->string('name')->nullable()->change();
});
}
public function down(): void
{
if (Schema::hasColumn('crm_invoices', 'location_id')) {
Schema::table('crm_invoices', function (Blueprint $table) {
$table->dropForeign(['location_id']);
$table->dropColumn('location_id');
});
}
Schema::table('crm_invoice_items', function (Blueprint $table) {
$table->string('name')->nullable(false)->change();
});
}
};

View File

@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Add cannaiq_brand_key column to brands table.
*
* This stores the normalized brand name used to query CannaIQ API.
* Example: "Aloha TymeMachine" "alohatymemachine"
*
* Security: This key is used to filter ALL CannaIQ API calls to only
* return data for this brand. Brands cannot see competitor data.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('brands', function (Blueprint $table) {
$table->string('cannaiq_brand_key')->nullable()->after('inbound_email_channel_id');
$table->index('cannaiq_brand_key');
});
}
public function down(): void
{
Schema::table('brands', function (Blueprint $table) {
$table->dropIndex(['cannaiq_brand_key']);
$table->dropColumn('cannaiq_brand_key');
});
}
};

View File

@@ -0,0 +1,65 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('banner_ads', function (Blueprint $table) {
$table->id();
// Ownership: null = platform-wide, brand_id = brand-specific ad
$table->foreignId('brand_id')->nullable()->constrained()->nullOnDelete();
$table->foreignId('created_by_user_id')->constrained('users');
// Content
$table->string('name'); // Internal name for admin
$table->string('headline')->nullable(); // Overlay headline
$table->text('description')->nullable(); // Overlay description
$table->string('cta_text', 50)->nullable(); // Button text (e.g., "Shop Now")
$table->string('cta_url', 500); // Click destination URL
// Image - stored in MinIO
$table->string('image_path'); // Full MinIO path
$table->string('image_alt')->nullable(); // Alt text for accessibility
// Placement & Dimensions
$table->string('zone', 50)->index(); // BannerAdZone enum value
// Scheduling
$table->timestamp('starts_at')->nullable();
$table->timestamp('ends_at')->nullable();
// Targeting
$table->json('target_business_types')->nullable(); // ['buyer', 'seller', 'both']
$table->boolean('is_platform_wide')->default(true);
// Status
$table->string('status', 20)->default('draft'); // draft, active, scheduled, paused, expired
// Priority for rotation (higher = shown more often)
$table->integer('priority')->default(0);
$table->integer('weight')->default(100); // For weighted random selection (1-1000)
// Stats (denormalized for fast reads)
$table->unsignedBigInteger('impressions')->default(0);
$table->unsignedBigInteger('clicks')->default(0);
$table->timestamps();
$table->softDeletes();
// Indexes
$table->index(['zone', 'status']);
$table->index(['status', 'starts_at', 'ends_at']);
$table->index(['brand_id', 'status']);
});
}
public function down(): void
{
Schema::dropIfExists('banner_ads');
}
};

View File

@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('banner_ad_events', function (Blueprint $table) {
$table->id();
$table->foreignId('banner_ad_id')->constrained()->cascadeOnDelete();
$table->foreignId('business_id')->nullable()->constrained()->nullOnDelete();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->string('event_type', 20)->index(); // impression, click
// Context
$table->string('session_id', 100)->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->string('page_url', 500)->nullable(); // Where ad was shown
$table->string('referer', 500)->nullable();
$table->timestamp('created_at')->index();
// Indexes for reporting
$table->index(['banner_ad_id', 'event_type', 'created_at']);
$table->index(['created_at', 'event_type']); // For daily rollups
});
}
public function down(): void
{
Schema::dropIfExists('banner_ad_events');
}
};

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('banner_ad_daily_stats', function (Blueprint $table) {
$table->id();
$table->foreignId('banner_ad_id')->constrained()->cascadeOnDelete();
$table->date('date')->index();
$table->unsignedInteger('impressions')->default(0);
$table->unsignedInteger('clicks')->default(0);
$table->unsignedInteger('unique_impressions')->default(0);
$table->unsignedInteger('unique_clicks')->default(0);
$table->timestamps();
$table->unique(['banner_ad_id', 'date']);
});
}
public function down(): void
{
Schema::dropIfExists('banner_ad_daily_stats');
}
};

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Create product_cannaiq_mappings pivot table.
*
* Maps Hub products to CannaiQ products (many-to-many).
* - One Hub product can map to multiple CannaiQ products (same product at different dispensaries)
* - One CannaiQ product can map to multiple Hub products (bundles, variants)
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('product_cannaiq_mappings', function (Blueprint $table) {
$table->id();
$table->foreignId('product_id')->constrained()->onDelete('cascade');
$table->bigInteger('cannaiq_product_id'); // CannaiQ product ID
$table->string('cannaiq_product_name'); // Denormalized for display
$table->string('cannaiq_store_id')->nullable(); // Optional store-specific mapping
$table->string('cannaiq_store_name')->nullable(); // Denormalized store name
$table->timestamps();
$table->unique(['product_id', 'cannaiq_product_id'], 'product_cannaiq_unique');
$table->index('cannaiq_product_id');
});
}
public function down(): void
{
Schema::dropIfExists('product_cannaiq_mappings');
}
};

View File

@@ -103,4 +103,4 @@ Most likely categories:
- [INVENTORY_REFACTORING_NEEDED.md](./INVENTORY_REFACTORING_NEEDED.md)
- [MODULE_FEATURE_TIERS.md](./MODULE_FEATURE_TIERS.md)
- [PR #53](https://code.cannabrands.app/Cannabrands/hub/pulls/53)
- [PR #53](https://git.spdy.io/Cannabrands/hub/pulls/53)

165
docs/WIREGUARD_VPN.md Normal file
View File

@@ -0,0 +1,165 @@
# WireGuard VPN Access
Access the Spdy.io internal network (10.100.0.0/16) from anywhere.
## Server Details
| Setting | Value |
|---------|-------|
| Endpoint | 185.149.70.86:51820 |
| Server Public Key | `1RjA1Y79htmW/PJdjlWLuJWEhAimUvZ1asvZPBgTJz4=` |
| VPN Network | 10.200.0.0/24 |
| Internal Network | 10.100.0.0/16 |
## Client Configs
Save any config below as `spdy-vpn.conf` and import into WireGuard.
### Client 1 (10.200.0.10)
```ini
[Interface]
PrivateKey = gAqMAKIMa7J0nsEf3aufQk2EDUCD6EZp7rWrL+KIbVE=
Address = 10.200.0.10/32
DNS = 1.1.1.1
[Peer]
PublicKey = 1RjA1Y79htmW/PJdjlWLuJWEhAimUvZ1asvZPBgTJz4=
Endpoint = 185.149.70.86:51820
AllowedIPs = 10.100.0.0/16, 10.200.0.0/24
PersistentKeepalive = 25
```
### Client 2 (10.200.0.11)
```ini
[Interface]
PrivateKey = UGJ+AqvLgChJbR4Ddsabqwu4GkhrpLlQjy42EQtd6UQ=
Address = 10.200.0.11/32
DNS = 1.1.1.1
[Peer]
PublicKey = 1RjA1Y79htmW/PJdjlWLuJWEhAimUvZ1asvZPBgTJz4=
Endpoint = 185.149.70.86:51820
AllowedIPs = 10.100.0.0/16, 10.200.0.0/24
PersistentKeepalive = 25
```
### Client 3 (10.200.0.12)
```ini
[Interface]
PrivateKey = mFl7sW4Tu0mmfQl5e8giBMXPIoM+2/7GFlbncRoAPGk=
Address = 10.200.0.12/32
DNS = 1.1.1.1
[Peer]
PublicKey = 1RjA1Y79htmW/PJdjlWLuJWEhAimUvZ1asvZPBgTJz4=
Endpoint = 185.149.70.86:51820
AllowedIPs = 10.100.0.0/16, 10.200.0.0/24
PersistentKeepalive = 25
```
### Client 4 (10.200.0.13)
```ini
[Interface]
PrivateKey = QGMDVy/VXCRVRZU09shvCPNaNaNy35rKhV5a0KbdM3o=
Address = 10.200.0.13/32
DNS = 1.1.1.1
[Peer]
PublicKey = 1RjA1Y79htmW/PJdjlWLuJWEhAimUvZ1asvZPBgTJz4=
Endpoint = 185.149.70.86:51820
AllowedIPs = 10.100.0.0/16, 10.200.0.0/24
PersistentKeepalive = 25
```
### Client 5 (10.200.0.14) - Kelly's Workstation
```ini
[Interface]
PrivateKey = yLyDJUaQJzjGvtNbNWGhfKRh9AVvLl4sUy+/b1Fdikk=
Address = 10.200.0.14/32
DNS = 1.1.1.1
[Peer]
PublicKey = 1RjA1Y79htmW/PJdjlWLuJWEhAimUvZ1asvZPBgTJz4=
Endpoint = 185.149.70.86:51820
AllowedIPs = 10.100.0.0/16, 10.200.0.0/24
PersistentKeepalive = 25
```
**Client Public Key:** `9SQT0qHwOzc9S+SdODPrmJAYEz1MK1zLZqZZeyWdtDc=`
**Config Location:** `/etc/wireguard/wg0.conf`
## Client Assignments
| Client | IP | Assigned To | Public Key |
|--------|-----|-------------|------------|
| 1 | 10.200.0.10 | Available | - |
| 2 | 10.200.0.11 | Available | - |
| 3 | 10.200.0.12 | Available | - |
| 4 | 10.200.0.13 | Available | - |
| 5 | 10.200.0.14 | Kelly's Workstation | `9SQT0qHwOzc9S+SdODPrmJAYEz1MK1zLZqZZeyWdtDc=` |
## How to Connect
### Linux
```bash
# Install
sudo apt install wireguard
# Save config
sudo nano /etc/wireguard/spdy-vpn.conf
# Paste one of the configs above
# Connect
sudo wg-quick up spdy-vpn
# Disconnect
sudo wg-quick down spdy-vpn
# Auto-start on boot
sudo systemctl enable wg-quick@spdy-vpn
```
### macOS / Windows / iOS / Android
1. Install WireGuard from App Store / Google Play / [wireguard.com](https://wireguard.com/install/)
2. Click "Add Tunnel" → "Create from file" or paste config
3. Toggle ON to connect
## Test Connection
```bash
ping 10.200.0.1 # VPN gateway
ping 10.100.9.70 # CI server
ping 10.100.6.50 # Database (db1)
ping 10.100.9.60 # Monitoring
```
## Internal Network Reference
| IP | Host | Service |
|----|------|---------|
| 10.100.6.10 | k8s-cp1 | K8s Control Plane |
| 10.100.7.10 | k8s-cp2 | K8s Control Plane |
| 10.100.8.10 | k8s-cp3 | K8s Control Plane |
| 10.100.6.40 | k8s-worker1 | K8s Worker |
| 10.100.7.40 | k8s-worker2 | K8s Worker |
| 10.100.8.40 | k8s-worker3 | K8s Worker |
| 10.100.9.40 | k8s-worker4 | K8s Worker |
| 10.100.6.50 | db1 | PostgreSQL Primary |
| 10.100.7.50 | db2 | PostgreSQL Secondary |
| 10.100.9.50 | redis1 | Redis + Reverb |
| 10.100.9.60 | monitoring | Grafana + Prometheus |
| 10.100.9.70 | ci | Woodpecker CI + WireGuard |
| 10.100.9.80 | minio | MinIO S3 Storage |
| 10.100.8.60 | lb2 | Load Balancer (cannaiq.co) |
## Server Location
WireGuard server runs on: `10.100.9.70` (CI server)
Config: `/etc/wireguard/wg0.conf`

View File

@@ -926,7 +926,7 @@ spec:
spec:
containers:
- name: migrate
image: code.cannabrands.app/cannabrands/hub:2025.10.4
image: git.spdy.io/cannabrands/hub:2025.10.4
command: ["php", "artisan", "migrate", "--force"]
env:
- name: DB_HOST

View File

@@ -2,7 +2,7 @@
## Overview
This document describes the automated cleanup policy for our Gitea Docker registry (`code.cannabrands.app/cannabrands/hub`). The cleanup rules automatically manage image retention to prevent unlimited storage growth while preserving important versions.
This document describes the automated cleanup policy for our Gitea Docker registry (`git.spdy.io/cannabrands/hub`). The cleanup rules automatically manage image retention to prevent unlimited storage growth while preserving important versions.
## Retention Policy
@@ -50,7 +50,7 @@ This document describes the automated cleanup policy for our Gitea Docker regist
### Gitea UI Location
1. Log in to `https://code.cannabrands.app`
1. Log in to `https://git.spdy.io`
2. Navigate to your organization: `Cannabrands`
3. Go to **Settings****Packages** → **Cleanup Rules**
4. Click **Add Cleanup Rule**
@@ -152,7 +152,7 @@ Check current registry usage:
2. View "Storage Used" metric
# Via Docker registry API (if available)
curl -u username:token https://code.cannabrands.app/v2/_catalog
curl -u username:token https://git.spdy.io/v2/_catalog
```
### 3. List Current Images
@@ -161,10 +161,10 @@ Check what's currently in the registry:
```bash
# List all tags for the hub repository
curl -s https://code.cannabrands.app/v2/cannabrands/hub/tags/list | jq .
curl -s https://git.spdy.io/v2/cannabrands/hub/tags/list | jq .
# Count tags by type
curl -s https://code.cannabrands.app/v2/cannabrands/hub/tags/list | \
curl -s https://git.spdy.io/v2/cannabrands/hub/tags/list | \
jq -r '.tags[]' | grep -c "^sha-" # Count SHA tags
```
@@ -219,8 +219,8 @@ Unfortunately, once Gitea deletes an image, it's **permanent**. Prevention strat
To rebuild from tag:
```bash
git checkout 2025.10.1
docker build -t code.cannabrands.app/cannabrands/hub:2025.10.1 .
docker push code.cannabrands.app/cannabrands/hub:2025.10.1
docker build -t git.spdy.io/cannabrands/hub:2025.10.1 .
docker push git.spdy.io/cannabrands/hub:2025.10.1
```
## Related Documentation

View File

@@ -284,7 +284,7 @@ To properly enforce code quality, you MUST configure branch protection rules in
#### Step 1: Enable Branch Protection
1. Go to your repository in Gitea: `code.cannabrands.app/cannabrands/hub`
1. Go to your repository in Gitea: `git.spdy.io/cannabrands/hub`
2. Click **Settings** → **Branches**
3. Click **Add Rule** or edit existing protection
@@ -620,9 +620,9 @@ If builds are slower than this:
**Deploys from**: `develop` branch (automatic on push)
**Docker image tags**:
- `code.cannabrands.app/cannabrands/hub:dev`
- `code.cannabrands.app/cannabrands/hub:sha-a1b2c3d`
- `code.cannabrands.app/cannabrands/hub:develop`
- `git.spdy.io/cannabrands/hub:dev`
- `git.spdy.io/cannabrands/hub:sha-a1b2c3d`
- `git.spdy.io/cannabrands/hub:develop`
**When to use**:
- ✅ Testing features merged from multiple developers
@@ -646,9 +646,9 @@ If builds are slower than this:
**Deploys from**: `master` branch (automatic on push)
**Docker image tags**:
- `code.cannabrands.app/cannabrands/hub:staging`
- `code.cannabrands.app/cannabrands/hub:sha-x7y8z9a`
- `code.cannabrands.app/cannabrands/hub:master`
- `git.spdy.io/cannabrands/hub:staging`
- `git.spdy.io/cannabrands/hub:sha-x7y8z9a`
- `git.spdy.io/cannabrands/hub:master`
**When to use**:
- ✅ Final validation before production release
@@ -672,8 +672,8 @@ If builds are slower than this:
**Deploys from**: Tagged releases only (e.g., `2025.10.1`)
**Docker image tags**:
- `code.cannabrands.app/cannabrands/hub:2025.10.1`
- `code.cannabrands.app/cannabrands/hub:latest`
- `git.spdy.io/cannabrands/hub:2025.10.1`
- `git.spdy.io/cannabrands/hub:latest`
**When to use**:
- ✅ Customer-facing application only
@@ -933,8 +933,8 @@ git push origin 2025.10.4
# APP_VERSION=2025.10.4
# 3. Docker image tagged as:
# code.cannabrands.app/cannabrands/hub:2025.10.4
# code.cannabrands.app/cannabrands/hub:latest
# git.spdy.io/cannabrands/hub:2025.10.4
# git.spdy.io/cannabrands/hub:latest
# 4. version.env created inside image:
# VERSION=2025.10.4
@@ -956,9 +956,9 @@ git push origin develop
# APP_VERSION=dev
# 3. Docker image tagged as:
# code.cannabrands.app/cannabrands/hub:dev
# code.cannabrands.app/cannabrands/hub:sha-x7y8z9a
# code.cannabrands.app/cannabrands/hub:develop
# git.spdy.io/cannabrands/hub:dev
# git.spdy.io/cannabrands/hub:sha-x7y8z9a
# git.spdy.io/cannabrands/hub:develop
# 4. version.env created inside image:
# VERSION=dev
@@ -980,9 +980,9 @@ git push origin master
# APP_VERSION=staging
# 3. Docker image tagged as:
# code.cannabrands.app/cannabrands/hub:staging
# code.cannabrands.app/cannabrands/hub:sha-m5n6o7p
# code.cannabrands.app/cannabrands/hub:master
# git.spdy.io/cannabrands/hub:staging
# git.spdy.io/cannabrands/hub:sha-m5n6o7p
# git.spdy.io/cannabrands/hub:master
# 4. version.env created inside image:
# VERSION=staging
@@ -1082,10 +1082,10 @@ Deployment to Production
Date: 2025-10-15 14:30:00 PST
Version: 2025.10.4
Commit: a1b2c3d
Image: code.cannabrands.app/cannabrands/hub:2025.10.4
Image: git.spdy.io/cannabrands/hub:2025.10.4
Deployed by: jon@cannabrands.com
Approved by: admin@cannabrands.com
Git tag: https://code.cannabrands.app/.../2025.10.4
Git tag: https://git.spdy.io/.../2025.10.4
Changes: Invoice calculation improvements
Tests passed: ✅ 156/156
Staging tested: ✅ 48 hours
@@ -1174,7 +1174,7 @@ When develop branch is stable and ready for stakeholder testing:
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Option A: Via Pull Request (RECOMMENDED)
# 1. Go to Gitea: code.cannabrands.app
# 1. Go to Gitea: git.spdy.io
# 2. Create PR: develop → master
# 3. Add description of changes
# 4. Request review from team
@@ -1240,11 +1240,11 @@ git push origin 2025.10.4
ssh user@cannabrands.app
# Pull new image
docker pull code.cannabrands.app/cannabrands/hub:2025.10.4
docker pull git.spdy.io/cannabrands/hub:2025.10.4
# Update docker-compose to use new tag
# Edit docker-compose.production.yml:
# image: code.cannabrands.app/cannabrands/hub:2025.10.4
# image: git.spdy.io/cannabrands/hub:2025.10.4
# Deploy with zero downtime
docker-compose -f docker-compose.production.yml pull
@@ -1277,7 +1277,7 @@ git push origin 2025.10.4
# 2. Deploy to Kubernetes (DevOps team)
kubectl set image deployment/cannabrands \
app=code.cannabrands.app/cannabrands/hub:2025.10.4 \
app=git.spdy.io/cannabrands/hub:2025.10.4 \
-n production
```
@@ -1579,7 +1579,7 @@ Key sections:
### Viewing CI Results
1. Go to Gitea: `code.cannabrands.app`
1. Go to Gitea: `git.spdy.io`
2. Navigate to repository
3. Click "Commits" or "Pull Requests"
4. Click on commit SHA to see pipeline status
@@ -1715,7 +1715,7 @@ See [DEVELOPMENT.md](./DEVELOPMENT.md) for complete local setup guide.
git tag --list --sort=-v:refname | head -5 # Find previous version
# SSH to production and deploy old version
docker pull code.cannabrands.app/cannabrands/hub:2025.10.3
docker pull git.spdy.io/cannabrands/hub:2025.10.3
# Update docker-compose.yml to use old tag
docker-compose -f docker-compose.production.yml up -d
```

View File

@@ -82,7 +82,7 @@ This guide covers deploying the Cannabrands application to Kubernetes clusters f
| **Production** | 3+ | 8 cores | 16 GB | 200 GB |
**Container Registry:**
- Gitea container registry: `code.cannabrands.app`
- Gitea container registry: `git.spdy.io`
- Or Docker Hub, AWS ECR, GCP GCR, etc.
### Required Tools
@@ -255,20 +255,20 @@ kubectl get namespaces
```bash
# Create Docker registry secret (for each namespace)
kubectl create secret docker-registry regcred \
--docker-server=code.cannabrands.app \
--docker-server=git.spdy.io \
--docker-username=<gitea-username> \
--docker-password=<gitea-token> \
-n production
# Repeat for staging and dev
kubectl create secret docker-registry regcred \
--docker-server=code.cannabrands.app \
--docker-server=git.spdy.io \
--docker-username=<gitea-username> \
--docker-password=<gitea-token> \
-n staging
kubectl create secret docker-registry regcred \
--docker-server=code.cannabrands.app \
--docker-server=git.spdy.io \
--docker-username=<gitea-username> \
--docker-password=<gitea-token> \
-n dev
@@ -534,7 +534,7 @@ spec:
containers:
- name: app
image: code.cannabrands.app/cannabrands/hub:2025.10.4
image: git.spdy.io/cannabrands/hub:2025.10.4
imagePullPolicy: IfNotPresent
ports:
@@ -684,7 +684,7 @@ git push origin 2025.10.4
```bash
# Check CI pipeline status in Gitea
# Verify image exists in registry
docker pull code.cannabrands.app/cannabrands/hub:2025.10.4
docker pull git.spdy.io/cannabrands/hub:2025.10.4
```
**3. Update Kubernetes Deployment:**
@@ -692,7 +692,7 @@ docker pull code.cannabrands.app/cannabrands/hub:2025.10.4
**Option A: Rolling update (recommended):**
```bash
kubectl set image deployment/cannabrands \
app=code.cannabrands.app/cannabrands/hub:2025.10.4 \
app=git.spdy.io/cannabrands/hub:2025.10.4 \
-n production
# Monitor rollout
@@ -774,7 +774,7 @@ kubectl get pods -n production -l app=cannabrands
# Option 2: Rollback to specific version
kubectl set image deployment/cannabrands \
app=code.cannabrands.app/cannabrands/hub:2025.10.3 \
app=git.spdy.io/cannabrands/hub:2025.10.3 \
-n production
# Option 3: Rollback to specific revision
@@ -938,7 +938,7 @@ kubectl describe pod <pod-name> -n production
**1. ImagePullBackOff - Image doesn't exist:**
```bash
# Verify image exists
docker pull code.cannabrands.app/cannabrands/hub:2025.10.4
docker pull git.spdy.io/cannabrands/hub:2025.10.4
# Check image name in deployment
kubectl get deployment cannabrands -n production -o yaml | grep image:
@@ -952,7 +952,7 @@ kubectl get secrets -n production | grep regcred
# Recreate secret
kubectl delete secret regcred -n production
kubectl create secret docker-registry regcred \
--docker-server=code.cannabrands.app \
--docker-server=git.spdy.io \
--docker-username=<username> \
--docker-password=<token> \
-n production
@@ -1102,7 +1102,7 @@ spec:
spec:
initContainers:
- name: migrate
image: code.cannabrands.app/cannabrands/hub:2025.10.4
image: git.spdy.io/cannabrands/hub:2025.10.4
command: ['php', 'artisan', 'migrate', '--force']
envFrom:
- configMapRef:
@@ -1124,7 +1124,7 @@ spec:
spec:
containers:
- name: migrate
image: code.cannabrands.app/cannabrands/hub:2025.10.4
image: git.spdy.io/cannabrands/hub:2025.10.4
command: ['php', 'artisan', 'migrate', '--force']
envFrom:
- configMapRef:
@@ -1260,7 +1260,7 @@ deploy-to-production:
from_secret: k8s_token
commands:
- kubectl set image deployment/cannabrands
app=code.cannabrands.app/cannabrands/hub:${CI_COMMIT_TAG}
app=git.spdy.io/cannabrands/hub:${CI_COMMIT_TAG}
-n production
when:
event: tag

View File

@@ -413,7 +413,7 @@ kubectl get secret -n cannabrands-dev gitea-registry
kubectl describe pod -n cannabrands-dev -l app=cannabrands-hub | grep -A 10 "Events:"
# Test image pull manually
kubectl run -n cannabrands-dev test-pull --image=code.cannabrands.app/cannabrands/hub:dev --image-pull-policy=Always --rm -it -- /bin/sh
kubectl run -n cannabrands-dev test-pull --image=git.spdy.io/cannabrands/hub:dev --image-pull-policy=Always --rm -it -- /bin/sh
```
### Performance Issues

View File

@@ -125,7 +125,7 @@ Create Docker registry pull secrets for each environment:
```bash
# Development
kubectl create secret docker-registry gitea-registry \
--docker-server=code.cannabrands.app \
--docker-server=git.spdy.io \
--docker-username=YOUR_USERNAME \
--docker-password=YOUR_GITEA_TOKEN \
--docker-email=your@email.com \
@@ -133,7 +133,7 @@ kubectl create secret docker-registry gitea-registry \
# Staging
kubectl create secret docker-registry gitea-registry \
--docker-server=code.cannabrands.app \
--docker-server=git.spdy.io \
--docker-username=YOUR_USERNAME \
--docker-password=YOUR_GITEA_TOKEN \
--docker-email=your@email.com \
@@ -141,7 +141,7 @@ kubectl create secret docker-registry gitea-registry \
# Production
kubectl create secret docker-registry gitea-registry \
--docker-server=code.cannabrands.app \
--docker-server=git.spdy.io \
--docker-username=YOUR_USERNAME \
--docker-password=YOUR_GITEA_TOKEN \
--docker-email=your@email.com \
@@ -149,7 +149,7 @@ kubectl create secret docker-registry gitea-registry \
```
**Get Gitea token:**
1. Login to https://code.cannabrands.app
1. Login to https://git.spdy.io
2. Settings → Applications → Generate New Token
3. Scope: `read:packages`
@@ -510,12 +510,12 @@ kubectl exec -it deployment/app -n development -- bash
```bash
# Development (automatic via CI)
# CI pushes code.cannabrands.app/cannabrands/hub:dev
# CI pushes git.spdy.io/cannabrands/hub:dev
kubectl rollout restart deployment/app -n development
# Production (manual)
# Tag new release: git tag 2025.10.1
# CI builds code.cannabrands.app/cannabrands/hub:latest
# CI builds git.spdy.io/cannabrands/hub:latest
kubectl rollout restart deployment/app -n production
# Monitor rollout

View File

@@ -40,7 +40,7 @@ This document describes the Kubernetes deployment configuration for Laravel Reve
**Purpose:** Runs Laravel Reverb WebSocket server for real-time broadcasting
**Configuration:**
- **Image:** Same as main app (`code.cannabrands.app/cannabrands/hub:latest`)
- **Image:** Same as main app (`git.spdy.io/cannabrands/hub:latest`)
- **Command:** `php artisan reverb:start --host=0.0.0.0 --port=8080`
- **Replicas:** 2 (for high availability)
- **Port:** 8080 (WebSocket)

View File

@@ -20,7 +20,7 @@ spec:
# Init container: Run migrations
initContainers:
- name: migrate
image: code.cannabrands.app/cannabrands/hub:latest
image: registry.spdy.io/cannabrands/hub:latest
imagePullPolicy: Always
command: ["/bin/sh", "-c"]
args:
@@ -47,7 +47,7 @@ spec:
containers:
- name: app
image: code.cannabrands.app/cannabrands/hub:latest
image: registry.spdy.io/cannabrands/hub:latest
imagePullPolicy: Always
ports:
- containerPort: 80

View File

@@ -19,7 +19,7 @@ spec:
containers:
- name: reverb
image: code.cannabrands.app/cannabrands/hub:latest
image: registry.spdy.io/cannabrands/hub:latest
imagePullPolicy: Always
command: ["php", "artisan", "reverb:start"]
args:

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