Files
hub/app/Http/Controllers/Seller/SettingsController.php
kelly 3984307e44
Some checks failed
ci/woodpecker/push/ci Pipeline failed
feat: add brand stores and orders dashboards with CannaiQ integration
- Add BrandStoresController with stores index, store detail, and orders pages
- Add routes for /brands/{brand}/stores and /brands/{brand}/orders
- Add stores_url and orders_url to brand tiles on index page
- Add getBrandStoreMetrics stub method to CannaiqClient
- Fix sidebar double-active issue with exact_match and url_fallback
- Fix user invite using wrong user_type (manufacturer -> seller)
2025-12-15 13:20:55 -07:00

1439 lines
57 KiB
PHP

<?php
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\DeliveryWindow;
use App\Models\Department;
use App\Models\DepartmentSuitePermission;
use App\Models\Driver;
use App\Models\Suite;
use App\Models\User;
use App\Models\Vehicle;
use Illuminate\Http\Request;
class SettingsController extends Controller
{
/**
* Display the settings dashboard with tiles for all settings areas.
* Only accessible by business Owner or Super Admin.
*/
public function index(Business $business)
{
// Authorization: Only Owner or Super Admin can access settings dashboard
$user = auth()->user();
$isOwner = $business->owner_user_id === $user->id;
$isSuperAdmin = $user->user_type === 'admin';
if (! $isOwner && ! $isSuperAdmin) {
abort(403, 'Only business owners and administrators can access settings.');
}
return view('seller.settings.index', compact('business'));
}
/**
* Display the consolidated deliveries settings page.
* Includes delivery windows, drivers, and vehicles.
*/
public function deliveries(Business $business)
{
$windows = DeliveryWindow::where('business_id', $business->id)
->orderBy('day_of_week')
->orderBy('start_time')
->get();
$drivers = Driver::where('business_id', $business->id)
->orderBy('first_name')
->get();
$vehicles = Vehicle::where('business_id', $business->id)
->orderBy('name')
->get();
return view('seller.settings.deliveries', compact('business', 'windows', 'drivers', 'vehicles'));
}
/**
* Display the company information settings page.
*/
public function companyInformation(Business $business)
{
return view('seller.settings.company-information', compact('business'));
}
/**
* Update the company information.
*/
public function updateCompanyInformation(Business $business, Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'dba_name' => 'nullable|string|max:255',
'description' => 'nullable|string|max:1000',
'business_type' => 'nullable|string',
'tin_ein' => 'nullable|string|max:20',
'license_number' => 'nullable|string|max:255',
'license_type' => 'nullable|string',
'physical_address' => 'nullable|string|max:255',
'physical_suite' => 'nullable|string|max:50',
'physical_city' => 'nullable|string|max:100',
'physical_state' => 'nullable|string|max:2',
'physical_zipcode' => 'nullable|string|max:10',
'business_phone' => 'nullable|string|max:20',
'business_email' => 'nullable|email|max:255',
'logo' => 'nullable|image|max:2048', // 2MB max
'banner' => 'nullable|image|max:4096', // 4MB max
'remove_logo' => 'nullable|boolean',
'remove_banner' => 'nullable|boolean',
]);
// Handle logo removal
if ($request->has('remove_logo') && $business->logo_path) {
\Storage::delete($business->logo_path);
$validated['logo_path'] = null;
}
// Handle logo upload
if ($request->hasFile('logo')) {
// Delete old logo if exists
if ($business->logo_path) {
\Storage::delete($business->logo_path);
}
$validated['logo_path'] = $request->file('logo')->store('businesses/logos', 'public');
}
// Handle banner removal
if ($request->has('remove_banner') && $business->banner_path) {
\Storage::delete($business->banner_path);
$validated['banner_path'] = null;
}
// Handle banner upload
if ($request->hasFile('banner')) {
// Delete old banner if exists
if ($business->banner_path) {
\Storage::delete($business->banner_path);
}
$validated['banner_path'] = $request->file('banner')->store('businesses/banners', 'public');
}
// Remove file inputs from validated data (already handled above)
unset($validated['logo'], $validated['banner'], $validated['remove_logo'], $validated['remove_banner']);
$business->update($validated);
// Return JSON for Precognition requests
if ($request->wantsJson()) {
return response()->json([
'message' => 'Company information updated successfully!',
]);
}
return redirect()
->route('seller.business.settings.company-information', $business->slug)
->with('success', 'Company information updated successfully!');
}
/**
* Display the users management settings page.
*/
public function users(Business $business, Request $request)
{
$query = $business->users();
// Search
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('name', 'ilike', "%{$search}%")
->orWhere('email', 'ilike', "%{$search}%");
});
}
// Filter by role
if ($request->filled('role')) {
$query->wherePivot('role', $request->role);
}
// Filter by department
if ($request->filled('department_id')) {
$query->whereHas('departments', function ($q) use ($request) {
$q->where('departments.id', $request->department_id);
});
}
// Filter by last login date range
if ($request->filled('last_login_start')) {
$query->where('last_login_at', '>=', $request->last_login_start);
}
if ($request->filled('last_login_end')) {
$query->where('last_login_at', '<=', $request->last_login_end.' 23:59:59');
}
$users = $query->with(['roles', 'departments'])->paginate(15);
// Get all departments for this business
$departments = Department::where('business_id', $business->id)
->orderBy('sort_order')
->get();
// Get business owner info
$owner = $business->owner;
// Get the suites assigned to this business for permission reference
$businessSuites = $business->suites()->active()->get();
return view('seller.settings.users', compact('business', 'users', 'departments', 'owner', 'businessSuites'));
}
/**
* Store a newly created user invitation.
*/
public function inviteUser(Business $business, Request $request)
{
$validated = $request->validate([
'first_name' => 'required|string|max:255',
'last_name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email',
'phone' => 'nullable|string|max:20',
'position' => 'nullable|string|max:255',
'role' => 'required|string|in:owner,admin,manager,member',
'department_ids' => 'nullable|array',
'department_ids.*' => 'exists:departments,id',
'is_point_of_contact' => 'nullable|boolean',
]);
// Combine first and last name
$fullName = trim($validated['first_name'].' '.$validated['last_name']);
// Create user and associate with business
$user = \App\Models\User::create([
'name' => $fullName,
'first_name' => $validated['first_name'],
'last_name' => $validated['last_name'],
'email' => $validated['email'],
'phone' => $validated['phone'] ?? null,
'position' => $validated['position'] ?? null,
'user_type' => 'seller', // Users in seller area are sellers
'password' => bcrypt(str()->random(32)), // Temporary password
]);
// Associate with business with role
$business->users()->attach($user->id, [
'role' => $validated['role'],
'is_primary' => false,
'contact_type' => $request->has('is_point_of_contact') ? 'primary' : null,
'permissions' => [], // Empty initially, assigned via department
]);
// Assign to departments if specified
if (! empty($validated['department_ids'])) {
foreach ($validated['department_ids'] as $departmentId) {
$user->departments()->attach($departmentId, [
'role' => 'operator', // Default department role
]);
}
}
// TODO: Send invitation email with password reset link
return redirect()
->route('seller.business.settings.users', $business->slug)
->with('success', 'User invited successfully!');
}
/**
* Remove user from business.
*/
public function removeUser(Business $business, \App\Models\User $user)
{
// Check if user belongs to this business
if (! $business->users()->where('users.id', $user->id)->exists()) {
abort(403, 'User does not belong to this business');
}
// Detach user from business
$business->users()->detach($user->id);
return redirect()
->route('seller.business.settings.users', $business->slug)
->with('success', 'User removed successfully!');
}
/**
* Show the edit user permissions page.
*/
public function editUser(Business $business, User $user)
{
// Check if user belongs to this business
if (! $user->businesses->contains($business->id)) {
abort(404, 'User not found in this business.');
}
// Load user's departments with pivot data
$user->load('departments');
// Check if user is the business owner
$isOwner = $business->owner_user_id === $user->id;
// Get all departments for this business and its parent
$departments = Department::query()
->where(function ($query) use ($business) {
$query->where('business_id', $business->id);
if ($business->parent_id) {
$query->orWhere('business_id', $business->parent_id);
}
})
->orderBy('business_id')
->orderBy('sort_order')
->get();
// Get the suites assigned to this business
$businessSuites = $business->suites()->active()->get();
// Build suite permissions structure based on business's assigned suites
$suitePermissions = $this->getSuitePermissions($businessSuites);
// Get user's current permissions from pivot
$pivotPermissions = $business->users()
->where('users.id', $user->id)
->first()
->pivot
->permissions ?? [];
// Ensure permissions is an array (JSON column may return string in PostgreSQL)
$userPermissions = is_array($pivotPermissions)
? $pivotPermissions
: (is_string($pivotPermissions) ? json_decode($pivotPermissions, true) ?? [] : []);
return view('seller.settings.users-edit', compact(
'business',
'user',
'isOwner',
'departments',
'businessSuites',
'suitePermissions',
'userPermissions'
));
}
/**
* Update user permissions and department assignments.
*/
public function updateUser(Business $business, User $user, Request $request)
{
// Check if user belongs to this business
if (! $user->businesses->contains($business->id)) {
abort(404, 'User not found in this business.');
}
// Check if user is the business owner
$isOwner = $business->owner_user_id === $user->id;
if ($isOwner) {
return redirect()
->route('seller.business.settings.users.edit', [$business->slug, $user->uuid])
->with('error', 'Cannot modify owner permissions.');
}
// Update user info
$user->update([
'position' => $request->input('position'),
'company' => $request->input('company'),
]);
// Update pivot table data
$business->users()->updateExistingPivot($user->id, [
'contact_type' => $request->input('contact_type'),
'role' => $request->input('role'),
'role_template' => $request->input('role_template'),
'permissions' => $request->input('permissions', []),
]);
// Update department assignments
$departmentAssignments = $request->input('departments', []);
$syncData = [];
foreach ($departmentAssignments as $departmentId => $data) {
if (isset($data['assigned']) && $data['assigned']) {
$syncData[$departmentId] = [
'role' => $data['role'] ?? 'operator',
];
}
}
// Sync departments (this will add new, update existing, and remove unchecked)
$user->departments()->sync($syncData);
return redirect()
->route('seller.business.settings.users.edit', [$business->slug, $user->uuid])
->with('success', 'User permissions and department assignments updated successfully!');
}
/**
* Get suite-based permission structure for the assigned suites.
*
* Groups permissions by functional area for better UX.
*/
private function getSuitePermissions($businessSuites): array
{
$suitePermissions = [];
foreach ($businessSuites as $suite) {
$permissions = DepartmentSuitePermission::getAvailablePermissions($suite->key);
if (empty($permissions)) {
continue;
}
// Group permissions by functional area
$grouped = $this->groupPermissionsByArea($permissions, $suite->key);
$suitePermissions[$suite->key] = [
'name' => $suite->name,
'description' => $suite->description,
'icon' => $suite->icon,
'color' => $suite->color,
'groups' => $grouped,
];
}
return $suitePermissions;
}
/**
* Group permissions by functional area for better organization.
*/
private function groupPermissionsByArea(array $permissions, string $suiteKey): array
{
// Define permission groupings based on prefix patterns
$areaPatterns = [
'dashboard' => ['view_dashboard', 'view_org_dashboard', 'view_analytics', 'view_all_analytics', 'export_analytics'],
'products' => ['view_products', 'manage_products', 'view_inventory', 'adjust_inventory', 'view_costs', 'view_margin'],
'orders' => ['view_orders', 'create_orders', 'manage_orders', 'view_invoices', 'create_invoices'],
'menus' => ['view_menus', 'manage_menus', 'view_promotions', 'manage_promotions'],
'campaigns' => ['view_campaigns', 'manage_campaigns', 'send_campaigns', 'manage_templates'],
'crm' => ['view_pipeline', 'edit_pipeline', 'manage_accounts', 'view_buyer_intelligence'],
'automations' => ['view_automations', 'manage_automations', 'use_copilot'],
'batches' => ['view_batches', 'manage_batches', 'create_batches'],
'processing' => ['view_wash_reports', 'create_wash_reports', 'edit_wash_reports', 'manage_extractions', 'view_yields', 'manage_biomass_intake', 'manage_material_transfers'],
'manufacturing' => ['view_bom', 'manage_bom', 'create_bom', 'view_production_queue', 'manage_production', 'manage_packaging', 'manage_labeling', 'create_skus', 'manage_lot_tracking'],
'work_orders' => ['view_work_orders', 'create_work_orders', 'manage_work_orders'],
'delivery' => ['view_pick_pack', 'manage_pick_pack', 'view_manifests', 'create_manifests', 'view_delivery_windows', 'manage_delivery_windows', 'view_drivers', 'manage_drivers', 'view_vehicles', 'manage_vehicles', 'view_routes', 'manage_routing', 'view_deliveries', 'complete_deliveries', 'manage_proof_of_delivery'],
'compliance' => ['view_compliance', 'manage_licenses', 'manage_coas', 'view_compliance_reports'],
'finance' => ['view_ap', 'manage_ap', 'pay_bills', 'view_ar', 'manage_ar', 'view_budgets', 'manage_budgets', 'approve_budget_exceptions', 'view_inter_company_ledger', 'manage_inter_company', 'view_financial_reports', 'export_financial_data', 'view_forecasting', 'manage_forecasting', 'view_kpis', 'view_usage_billing', 'manage_billing', 'view_cross_business'],
'messaging' => ['view_conversations', 'send_messages', 'manage_contacts'],
'procurement' => ['view_vendors', 'manage_vendors', 'view_requisitions', 'create_requisitions', 'approve_requisitions', 'view_purchase_orders', 'create_purchase_orders', 'receive_goods'],
'tools' => ['manage_settings', 'manage_users', 'manage_departments', 'view_audit_log', 'manage_integrations'],
'marketplace' => ['view_marketplace', 'browse_products', 'manage_cart', 'manage_favorites', 'view_buyer_portal', 'view_account'],
'brand_view' => ['view_sales', 'view_buyers'],
];
$areaLabels = [
'dashboard' => ['name' => 'Dashboard & Analytics', 'icon' => 'lucide--layout-dashboard'],
'products' => ['name' => 'Products & Inventory', 'icon' => 'lucide--package'],
'orders' => ['name' => 'Orders & Invoicing', 'icon' => 'lucide--shopping-cart'],
'menus' => ['name' => 'Menus & Promotions', 'icon' => 'lucide--menu'],
'campaigns' => ['name' => 'Campaigns & Marketing', 'icon' => 'lucide--megaphone'],
'crm' => ['name' => 'CRM & Accounts', 'icon' => 'lucide--users'],
'automations' => ['name' => 'Automations & AI', 'icon' => 'lucide--bot'],
'batches' => ['name' => 'Batches', 'icon' => 'lucide--layers'],
'processing' => ['name' => 'Processing Operations', 'icon' => 'lucide--flask-conical'],
'manufacturing' => ['name' => 'Manufacturing', 'icon' => 'lucide--factory'],
'work_orders' => ['name' => 'Work Orders', 'icon' => 'lucide--clipboard-list'],
'delivery' => ['name' => 'Delivery & Fulfillment', 'icon' => 'lucide--truck'],
'compliance' => ['name' => 'Compliance', 'icon' => 'lucide--shield-check'],
'finance' => ['name' => 'Finance & Budgets', 'icon' => 'lucide--banknote'],
'messaging' => ['name' => 'Messaging', 'icon' => 'lucide--message-square'],
'procurement' => ['name' => 'Procurement', 'icon' => 'lucide--clipboard-list'],
'tools' => ['name' => 'Tools & Settings', 'icon' => 'lucide--settings'],
'marketplace' => ['name' => 'Marketplace', 'icon' => 'lucide--store'],
'brand_view' => ['name' => 'Brand Data', 'icon' => 'lucide--eye'],
];
$grouped = [];
$assigned = [];
foreach ($areaPatterns as $area => $areaPermissions) {
$matchedPermissions = [];
foreach ($permissions as $key => $description) {
if (in_array($key, $areaPermissions) && ! isset($assigned[$key])) {
$matchedPermissions[$key] = $description;
$assigned[$key] = true;
}
}
if (! empty($matchedPermissions)) {
$grouped[$area] = [
'name' => $areaLabels[$area]['name'] ?? ucwords(str_replace('_', ' ', $area)),
'icon' => $areaLabels[$area]['icon'] ?? 'lucide--folder',
'permissions' => $matchedPermissions,
];
}
}
// Add any remaining permissions to "Other"
$remaining = [];
foreach ($permissions as $key => $description) {
if (! isset($assigned[$key])) {
$remaining[$key] = $description;
}
}
if (! empty($remaining)) {
$grouped['other'] = [
'name' => 'Other',
'icon' => 'lucide--more-horizontal',
'permissions' => $remaining,
];
}
return $grouped;
}
/**
* Display the order settings page.
*/
public function orders(Business $business)
{
return view('seller.settings.orders', compact('business'));
}
/**
* Update the order settings.
*/
public function updateOrders(Business $business, Request $request)
{
$validated = $request->validate([
'separate_orders_by_brand' => 'nullable|boolean',
'auto_increment_order_ids' => 'nullable|boolean',
'show_mark_as_paid' => 'nullable|boolean',
'display_crm_license_on_orders' => 'nullable|boolean',
'order_minimum' => 'nullable|numeric|min:0',
'default_shipping_charge' => 'nullable|numeric|min:0',
'free_shipping_minimum' => 'nullable|numeric|min:0',
'order_disclaimer' => 'nullable|string|max:2000',
'order_invoice_footer' => 'nullable|string|max:1000',
'prevent_order_editing' => 'required|in:never,after_approval,after_fulfillment,always',
'az_require_patient_count' => 'nullable|boolean',
'az_require_allotment_verification' => 'nullable|boolean',
]);
// Convert checkbox values (null means unchecked)
$validated['separate_orders_by_brand'] = $request->has('separate_orders_by_brand');
$validated['auto_increment_order_ids'] = $request->has('auto_increment_order_ids');
$validated['show_mark_as_paid'] = $request->has('show_mark_as_paid');
$validated['display_crm_license_on_orders'] = $request->has('display_crm_license_on_orders');
$validated['az_require_patient_count'] = $request->has('az_require_patient_count');
$validated['az_require_allotment_verification'] = $request->has('az_require_allotment_verification');
$business->update($validated);
return redirect()
->route('seller.business.settings.orders', $business->slug)
->with('success', 'Order settings updated successfully!');
}
/**
* Display the payment settings page.
*/
public function payments(Business $business)
{
return view('seller.settings.payments', compact('business'));
}
/**
* Display the invoice settings page.
*/
public function invoices(Business $business)
{
return view('seller.settings.invoices', compact('business'));
}
/**
* Update the invoice settings.
*/
public function updateInvoices(Business $business, Request $request)
{
$validated = $request->validate([
'invoice_payable_company_name' => 'nullable|string|max:255',
'invoice_payable_address' => 'nullable|string|max:255',
'invoice_payable_city' => 'nullable|string|max:100',
'invoice_payable_state' => 'nullable|string|max:2',
'invoice_payable_zipcode' => 'nullable|string|max:10',
]);
$business->update($validated);
return redirect()
->route('seller.business.settings.invoices', $business->slug)
->with('success', 'Invoice settings updated successfully!');
}
/**
* Display the manage licenses page.
* Only available to Processing/Manufacturing/Compliance businesses.
*/
public function manageLicenses(Business $business)
{
// Gate: Sales Suite-only businesses don't need license management
if (! $business->requiresLicenseManagement()) {
return redirect()
->route('seller.business.settings.index', $business->slug)
->with('info', 'License management is available for processing and manufacturing operations.');
}
// Load locations with license data
$locations = $business->locations()->orderBy('name')->get();
// Find locations with expired licenses
$expiredLocations = $locations->filter(function ($location) {
return $location->license_expiration && $location->license_expiration->isPast();
});
// Find locations with licenses expiring within 30 days
$expiringLocations = $locations->filter(function ($location) {
return $location->license_expiration
&& $location->license_expiration->isFuture()
&& $location->license_expiration->diffInDays(now()) <= 30;
});
return view('seller.settings.manage-licenses', compact(
'business',
'locations',
'expiredLocations',
'expiringLocations'
));
}
/**
* Display the plans and billing page.
*/
public function plansAndBilling(Business $business)
{
return view('seller.settings.plans-and-billing', compact('business'));
}
/**
* Display the notification preferences page.
*/
public function notifications(Business $business)
{
// Only business owners and super admins can manage business notifications
$user = auth()->user();
$isOwner = $business->owner_user_id === $user->id;
$isSuperAdmin = $user->hasRole('Super Admin');
if (! $isOwner && ! $isSuperAdmin) {
abort(403, 'Only business owners can manage notification settings.');
}
return view('seller.settings.notifications', compact('business'));
}
/**
* Update the notification settings.
*
* EMAIL NOTIFICATION RULES DOCUMENTATION:
*
* 1. NEW ORDER EMAIL NOTIFICATIONS (new_order_email_notifications)
* Base: Email these addresses when a new order is placed
* - If 'new_order_only_when_no_sales_rep' checked: ONLY send if buyer has NO sales rep assigned
* - If 'new_order_do_not_send_to_admins' checked: Do NOT send to company admins (only to these addresses)
*
* 2. ORDER ACCEPTED EMAIL NOTIFICATIONS (order_accepted_email_notifications)
* Base: Email these addresses when an order is accepted
* - If 'enable_shipped_emails_for_sales_reps' checked: Sales reps assigned to customer get email when order marked Shipped
*
* 3. PLATFORM INQUIRY EMAIL NOTIFICATIONS (platform_inquiry_email_notifications)
* Base: Email these addresses for inquiries
* - Sales reps associated with customer ALWAYS receive email
* - If field is blank AND no sales reps exist: company admins receive notifications
*
* 4. MANUAL ORDER EMAIL NOTIFICATIONS
* - If 'enable_manual_order_email_notifications' checked: Send same emails for manual orders as buyer-created orders
* - If 'enable_manual_order_email_notifications' unchecked: Only send for buyer-created orders
* - If 'manual_order_emails_internal_only' checked: Send manual order emails to internal recipients only (not buyers)
*
* 5. LOW INVENTORY EMAIL NOTIFICATIONS (low_inventory_email_notifications)
* Base: Email these addresses when inventory is low
*
* 6. CERTIFIED SELLER STATUS EMAIL NOTIFICATIONS (certified_seller_status_email_notifications)
* Base: Email these addresses when seller status changes
*/
public function updateNotifications(Business $business, Request $request)
{
$validated = $request->validate([
'new_order_email_notifications' => ['nullable', 'string', new \App\Rules\CommaSeparatedEmails],
'new_order_only_when_no_sales_rep' => 'nullable|boolean',
'new_order_do_not_send_to_admins' => 'nullable|boolean',
'order_accepted_email_notifications' => ['nullable', 'string', new \App\Rules\CommaSeparatedEmails],
'enable_shipped_emails_for_sales_reps' => 'nullable|boolean',
'platform_inquiry_email_notifications' => ['nullable', 'string', new \App\Rules\CommaSeparatedEmails],
'enable_manual_order_email_notifications' => 'nullable|boolean',
'manual_order_emails_internal_only' => 'nullable|boolean',
'low_inventory_email_notifications' => ['nullable', 'string', new \App\Rules\CommaSeparatedEmails],
'certified_seller_status_email_notifications' => ['nullable', 'string', new \App\Rules\CommaSeparatedEmails],
// CRM Notification Settings
'crm_task_reminder_enabled' => 'nullable|boolean',
'crm_task_reminder_minutes' => 'nullable|integer|min:5|max:1440',
'crm_event_reminder_enabled' => 'nullable|boolean',
'crm_event_reminder_minutes' => 'nullable|integer|min:5|max:1440',
'crm_daily_digest_enabled' => 'nullable|boolean',
'crm_daily_digest_time' => 'nullable|string|date_format:H:i',
'crm_notification_emails' => ['nullable', 'string', new \App\Rules\CommaSeparatedEmails],
'crm_notify_on_opportunity_stage_change' => 'nullable|boolean',
'crm_notify_on_task_assigned' => 'nullable|boolean',
'crm_notify_on_task_due_today' => 'nullable|boolean',
'crm_notify_on_overdue_tasks' => 'nullable|boolean',
]);
// Convert checkbox values (null means unchecked)
$validated['new_order_only_when_no_sales_rep'] = $request->has('new_order_only_when_no_sales_rep');
$validated['new_order_do_not_send_to_admins'] = $request->has('new_order_do_not_send_to_admins');
$validated['enable_shipped_emails_for_sales_reps'] = $request->has('enable_shipped_emails_for_sales_reps');
$validated['enable_manual_order_email_notifications'] = $request->has('enable_manual_order_email_notifications');
$validated['manual_order_emails_internal_only'] = $request->has('manual_order_emails_internal_only');
// CRM notification checkbox values
if ($business->hasCrmAccess()) {
$validated['crm_task_reminder_enabled'] = $request->has('crm_task_reminder_enabled');
$validated['crm_event_reminder_enabled'] = $request->has('crm_event_reminder_enabled');
$validated['crm_daily_digest_enabled'] = $request->has('crm_daily_digest_enabled');
$validated['crm_notify_on_opportunity_stage_change'] = $request->has('crm_notify_on_opportunity_stage_change');
$validated['crm_notify_on_task_assigned'] = $request->has('crm_notify_on_task_assigned');
$validated['crm_notify_on_task_due_today'] = $request->has('crm_notify_on_task_due_today');
$validated['crm_notify_on_overdue_tasks'] = $request->has('crm_notify_on_overdue_tasks');
}
$business->update($validated);
// Return JSON for Precognition requests
if ($request->wantsJson()) {
return response()->json([
'message' => 'Notification settings updated successfully!',
]);
}
return redirect()
->route('seller.business.settings.notifications', $business->slug)
->with('success', 'Notification settings updated successfully!');
}
/**
* Display the report settings page.
*/
public function reports(Business $business)
{
return view('seller.settings.reports', compact('business'));
}
/**
* Display the personal profile page.
*/
public function profile(Business $business)
{
$user = auth()->user();
$loginHistory = collect(); // Placeholder - will be implemented with login history tracking
return view('seller.settings.profile', compact('business', 'loginHistory'));
}
/**
* Update the personal profile.
*/
public function updateProfile(Business $business, Request $request)
{
$user = auth()->user();
$validated = $request->validate([
'first_name' => 'required|string|max:255',
'last_name' => 'required|string|max:255',
'email' => 'required|email|max:255|unique:users,email,'.$user->id,
'avatar' => 'nullable|image|max:2048',
'remove_avatar' => 'nullable|boolean',
'use_gravatar' => 'nullable|boolean',
'linkedin_url' => 'nullable|url|max:255',
'twitter_url' => 'nullable|url|max:255',
'facebook_url' => 'nullable|url|max:255',
'instagram_url' => 'nullable|url|max:255',
'github_url' => 'nullable|url|max:255',
]);
// Handle avatar removal
if ($request->has('remove_avatar') && $user->avatar_path) {
\Storage::delete($user->avatar_path);
$validated['avatar_path'] = null;
}
// Handle avatar upload
if ($request->hasFile('avatar')) {
// Delete old avatar if exists
if ($user->avatar_path) {
\Storage::delete($user->avatar_path);
}
$path = $request->file('avatar')->store('avatars', 'public');
$validated['avatar_path'] = $path;
}
$user->update($validated);
// Return JSON for Precognition requests
if ($request->wantsJson()) {
return response()->json([
'message' => 'Profile updated successfully.',
]);
}
return redirect()->route('seller.business.settings.profile', $business->slug)
->with('success', 'Profile updated successfully.');
}
/**
* Update the user's password.
*/
public function updatePassword(Business $business, Request $request)
{
$user = auth()->user();
$validated = $request->validate([
'current_password' => 'required|current_password',
'password' => 'required|string|min:8|confirmed',
'logout_other_sessions' => 'nullable|boolean',
]);
$user->update([
'password' => bcrypt($validated['password']),
]);
// Logout other sessions if requested
if ($request->has('logout_other_sessions')) {
auth()->logoutOtherDevices($validated['password']);
}
// Return JSON for Precognition requests
if ($request->wantsJson()) {
return response()->json([
'message' => 'Password updated successfully.',
]);
}
return redirect()->route('seller.business.settings.profile', $business->slug)
->with('success', 'Password updated successfully.');
}
/**
* Display the sales configuration page (orders + invoices).
*/
public function salesConfig(Business $business)
{
return view('seller.settings.sales-config', compact('business'));
}
/**
* Update the sales configuration settings (orders + invoices).
*/
public function updateSalesConfig(Business $business, Request $request)
{
$validated = $request->validate([
// Order settings
'separate_orders_by_brand' => 'nullable|boolean',
'auto_increment_order_ids' => 'nullable|boolean',
'show_mark_as_paid' => 'nullable|boolean',
'display_crm_license_on_orders' => 'nullable|boolean',
'order_minimum' => 'nullable|numeric|min:0',
'default_shipping_charge' => 'nullable|numeric|min:0',
'free_shipping_minimum' => 'nullable|numeric|min:0',
'order_disclaimer' => 'nullable|string|max:2000',
'order_invoice_footer' => 'nullable|string|max:1000',
'prevent_order_editing' => 'required|in:never,after_approval,after_fulfillment,always',
'az_require_patient_count' => 'nullable|boolean',
'az_require_allotment_verification' => 'nullable|boolean',
// Invoice settings
'invoice_payable_company_name' => 'nullable|string|max:255',
'invoice_payable_address' => 'nullable|string|max:255',
'invoice_payable_city' => 'nullable|string|max:100',
'invoice_payable_state' => 'nullable|string|max:2',
'invoice_payable_zipcode' => 'nullable|string|max:10',
]);
// Convert checkbox values (null means unchecked)
$validated['separate_orders_by_brand'] = $request->has('separate_orders_by_brand');
$validated['auto_increment_order_ids'] = $request->has('auto_increment_order_ids');
$validated['show_mark_as_paid'] = $request->has('show_mark_as_paid');
$validated['display_crm_license_on_orders'] = $request->has('display_crm_license_on_orders');
$validated['az_require_patient_count'] = $request->has('az_require_patient_count');
$validated['az_require_allotment_verification'] = $request->has('az_require_allotment_verification');
$business->update($validated);
// Return JSON for Precognition requests
if ($request->wantsJson()) {
return response()->json([
'message' => 'Sales configuration updated successfully!',
]);
}
return redirect()
->route('seller.business.settings.sales-config', $business->slug)
->with('success', 'Sales configuration updated successfully!');
}
/**
* Display the brand kit page (Cannabrands assets/branding settings).
*/
public function brandKit(Business $business)
{
return view('seller.settings.brand-kit', compact('business'));
}
/**
* Display the integrations page.
*/
public function integrations(Business $business)
{
return view('seller.settings.integrations', compact('business'));
}
/**
* Display the webhooks / API page.
*/
public function webhooks(Business $business)
{
return view('seller.settings.webhooks', compact('business'));
}
/**
* Display the audit logs page.
*/
public function auditLogs(Business $business, Request $request)
{
// CRITICAL: Only show audit logs for THIS business (multi-tenancy)
$query = \App\Models\AuditLog::forBusiness($business->id)
->with(['user', 'auditable']);
// Search filter
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('description', 'ilike', "%{$search}%")
->orWhere('event', 'ilike', "%{$search}%")
->orWhereHas('user', function ($userQuery) use ($search) {
$userQuery->where('name', 'ilike', "%{$search}%")
->orWhere('email', 'ilike', "%{$search}%");
});
});
}
// Filter by event type
if ($request->filled('event')) {
$query->byEvent($request->event);
}
// Filter by auditable type (resource type)
if ($request->filled('type')) {
$query->byType($request->type);
}
// Filter by user
if ($request->filled('user_id')) {
$query->forUser($request->user_id);
}
// Filter by date range
if ($request->filled('start_date')) {
$query->where('created_at', '>=', $request->start_date);
}
if ($request->filled('end_date')) {
$query->where('created_at', '<=', $request->end_date.' 23:59:59');
}
// Get paginated results, ordered by most recent first
$audits = $query->latest('created_at')->paginate(50);
// Get unique event types for filter dropdown
$eventTypes = \App\Models\AuditLog::forBusiness($business->id)
->select('event')
->distinct()
->pluck('event')
->sort();
// Get unique auditable types for filter dropdown
$auditableTypes = \App\Models\AuditLog::forBusiness($business->id)
->select('auditable_type')
->whereNotNull('auditable_type')
->distinct()
->get()
->map(function ($log) {
$parts = explode('\\', $log->auditable_type);
return end($parts);
})
->unique()
->sort();
// Get pruning settings for this business
$pruningSettings = \App\Models\AuditPruningSettings::getForBusiness($business->id);
return view('seller.settings.audit-logs', compact('business', 'audits', 'eventTypes', 'auditableTypes', 'pruningSettings'));
}
/**
* Restore a model to its previous state from an audit log
*/
public function restoreAudit(Business $business, Request $request, $auditId)
{
// Find the audit record
$audit = \App\Models\AuditLog::findOrFail($auditId);
// Validate that this audit can be restored
if ($audit->event !== 'updated') {
return response()->json([
'success' => false,
'message' => 'Only updated records can be restored. Created or deleted records cannot be reverted.',
], 422);
}
if (empty($audit->old_values)) {
return response()->json([
'success' => false,
'message' => 'No previous values found for this audit entry.',
], 422);
}
try {
// Get the auditable model
$modelClass = $audit->auditable_type;
$model = $modelClass::find($audit->auditable_id);
if (! $model) {
return response()->json([
'success' => false,
'message' => 'The original record no longer exists and cannot be restored.',
], 404);
}
// CRITICAL: Verify business ownership for multi-tenancy
// Check if model has business_id and belongs to current business
if (isset($model->business_id) && $model->business_id !== $business->id) {
return response()->json([
'success' => false,
'message' => 'You do not have permission to restore this record.',
], 403);
}
// For models that belong to business through brand (like Product)
if (method_exists($model, 'brand') && $model->brand && $model->brand->business_id !== $business->id) {
return response()->json([
'success' => false,
'message' => 'You do not have permission to restore this record.',
], 403);
}
// Restore the old values
$restoredFields = [];
foreach ($audit->old_values as $field => $value) {
// Skip fields that shouldn't be restored
$skipFields = ['created_at', 'updated_at', 'deleted_at', 'id', 'password'];
if (in_array($field, $skipFields)) {
continue;
}
// Only restore if the field exists on the model
if (array_key_exists($field, $model->getAttributes())) {
$model->{$field} = $value;
$restoredFields[] = $field;
}
}
if (empty($restoredFields)) {
return response()->json([
'success' => false,
'message' => 'No restorable fields found in this audit entry.',
], 422);
}
// Save the model (this will create a new audit entry automatically)
$model->save();
// Add custom audit tag to indicate this was a restore operation
if (method_exists($model, 'audits')) {
$latestAudit = $model->audits()->latest()->first();
if ($latestAudit) {
$latestAudit->tags = 'restored_from_audit_'.$audit->id;
$latestAudit->save();
}
}
return response()->json([
'success' => true,
'message' => 'Record successfully restored to previous state.',
'restored_fields' => $restoredFields,
'model_type' => class_basename($modelClass),
'model_id' => $model->id,
]);
} catch (\Exception $e) {
\Log::error('Audit restore failed', [
'audit_id' => $auditId,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
return response()->json([
'success' => false,
'message' => 'Failed to restore record: '.$e->getMessage(),
], 500);
}
}
/**
* Update audit pruning settings
*/
public function updateAuditPruning(Business $business, Request $request)
{
$validated = $request->validate([
'enabled' => 'required|boolean',
'strategy' => 'required|in:revisions,time,hybrid',
'keep_revisions' => 'nullable|integer|min:1|max:100',
'keep_days' => 'nullable|integer|min:1|max:90', // HARD LIMIT: 90 days max
]);
$settings = \App\Models\AuditPruningSettings::getForBusiness($business->id);
$settings->update($validated);
return response()->json([
'success' => true,
'message' => 'Audit pruning settings updated successfully.',
'settings' => $settings,
'description' => $settings->strategy_description,
]);
}
/**
* Export audit logs to CSV with filters
*/
public function exportAuditLogs(Business $business, Request $request)
{
// CRITICAL: Only export audit logs for THIS business (multi-tenancy)
$query = \App\Models\AuditLog::forBusiness($business->id)
->with(['user', 'auditable']);
// Apply same filters as auditLogs method
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('description', 'ilike', "%{$search}%")
->orWhere('event', 'ilike', "%{$search}%")
->orWhereHas('user', function ($userQuery) use ($search) {
$userQuery->where('name', 'ilike', "%{$search}%")
->orWhere('email', 'ilike', "%{$search}%");
});
});
}
if ($request->filled('event')) {
$query->byEvent($request->event);
}
if ($request->filled('type')) {
$query->byType($request->type);
}
if ($request->filled('user_id')) {
$query->forUser($request->user_id);
}
if ($request->filled('start_date')) {
$query->where('created_at', '>=', $request->start_date);
}
if ($request->filled('end_date')) {
$query->where('created_at', '<=', $request->end_date.' 23:59:59');
}
// Get all matching audits (no pagination for export)
$audits = $query->latest('created_at')->get();
// Generate CSV filename with timestamp
$filename = 'audit-logs-'.now()->format('Y-m-d-His').'.csv';
// Create CSV in memory
$handle = fopen('php://temp', 'r+');
// Write CSV header
fputcsv($handle, [
'Timestamp',
'User',
'Email',
'Action',
'Resource Type',
'Resource ID',
'IP Address',
'Changes',
]);
// Write data rows
foreach ($audits as $audit) {
// Format changes
$changes = '';
if (! empty($audit->old_values) || ! empty($audit->new_values)) {
$changeDetails = [];
$allKeys = array_unique(array_merge(
array_keys($audit->old_values ?? []),
array_keys($audit->new_values ?? [])
));
foreach ($allKeys as $key) {
$oldValue = $audit->old_values[$key] ?? 'null';
$newValue = $audit->new_values[$key] ?? 'null';
// Truncate long values for CSV readability
if (is_string($oldValue) && strlen($oldValue) > 50) {
$oldValue = substr($oldValue, 0, 47).'...';
}
if (is_string($newValue) && strlen($newValue) > 50) {
$newValue = substr($newValue, 0, 47).'...';
}
$changeDetails[] = "{$key}: {$oldValue}{$newValue}";
}
$changes = implode(' | ', $changeDetails);
}
fputcsv($handle, [
$audit->created_at->format('Y-m-d H:i:s'),
$audit->user?->name ?? 'System',
$audit->user?->email ?? 'N/A',
ucfirst($audit->event),
class_basename($audit->auditable_type ?? 'Unknown'),
$audit->auditable_id ?? 'N/A',
$audit->ip_address ?? 'N/A',
$changes,
]);
}
// Rewind the file pointer
rewind($handle);
// Get the CSV content
$csv = stream_get_contents($handle);
fclose($handle);
// Return CSV download response
return response($csv, 200, [
'Content-Type' => 'text/csv',
'Content-Disposition' => "attachment; filename=\"{$filename}\"",
'Cache-Control' => 'no-cache, no-store, must-revalidate',
'Pragma' => 'no-cache',
'Expires' => '0',
]);
}
public function changePlan(Business $business, Request $request)
{
$validated = $request->validate([
'plan_id' => 'required|in:standard,business,premium',
]);
$planId = $validated['plan_id'];
// Define available plans with pricing
$plans = [
'standard' => ['name' => 'Marketplace Standard', 'price' => 99.00],
'business' => ['name' => 'Marketplace Business', 'price' => 395.00],
'premium' => ['name' => 'Marketplace Premium', 'price' => 795.00],
];
$newPlan = $plans[$planId];
// Get or create subscription
$subscription = $business->subscription()->firstOrCreate(
['business_id' => $business->id],
[
'plan_id' => 'standard',
'plan_name' => 'Marketplace Standard',
'plan_price' => 99.00,
'status' => 'active',
'current_period_start' => now(),
'current_period_end' => now()->addMonth(),
]
);
// Check if same plan
if ($subscription->plan_id === $planId) {
return redirect()
->route('seller.business.settings.plans-and-billing', $business->slug)
->with('info', 'You are already on this plan.');
}
// Determine if upgrade or downgrade
$isUpgrade = $newPlan['price'] > $subscription->plan_price;
if ($isUpgrade) {
// UPGRADE: Calculate prorated charge and update immediately
$daysLeftInCycle = now()->diffInDays($subscription->current_period_end);
$proratedCredit = ($subscription->plan_price / 30) * $daysLeftInCycle;
$proratedCharge = ($newPlan['price'] / 30) * $daysLeftInCycle;
$amountToPay = $proratedCharge - $proratedCredit;
// Create invoice for the upgrade
$invoiceNumber = 'INV-'.now()->format('Y').'-'.str_pad(\App\Models\SubscriptionInvoice::count() + 1, 5, '0', STR_PAD_LEFT);
$invoice = \App\Models\SubscriptionInvoice::create([
'subscription_id' => $subscription->id,
'business_id' => $business->id,
'invoice_number' => $invoiceNumber,
'type' => 'upgrade',
'amount' => $amountToPay,
'status' => 'pending',
'invoice_date' => now(),
'due_date' => now()->addDays(7),
'line_items' => [
[
'description' => "{$newPlan['name']} (prorated for {$daysLeftInCycle} days)",
'amount' => $proratedCharge,
],
[
'description' => "Credit from {$subscription->plan_name}",
'amount' => -$proratedCredit,
],
],
'payment_method_id' => $subscription->default_payment_method_id,
]);
// Update subscription to new plan immediately
$subscription->update([
'plan_id' => $planId,
'plan_name' => $newPlan['name'],
'plan_price' => $newPlan['price'],
'scheduled_plan_id' => null,
'scheduled_plan_name' => null,
'scheduled_plan_price' => null,
'scheduled_change_date' => null,
]);
// TODO: Charge the payment method for $amountToPay
// TODO: Mark invoice as paid after successful charge
return redirect()
->route('seller.business.settings.plans-and-billing', $business->slug)
->with('success', sprintf(
'Plan upgraded to %s! Invoice %s created for $%s (prorated). New features are active immediately.',
$newPlan['name'],
$invoiceNumber,
number_format($amountToPay, 2)
));
} else {
// DOWNGRADE: Schedule for next billing cycle
$subscription->update([
'scheduled_plan_id' => $planId,
'scheduled_plan_name' => $newPlan['name'],
'scheduled_plan_price' => $newPlan['price'],
'scheduled_change_date' => $subscription->current_period_end,
]);
return redirect()
->route('seller.business.settings.plans-and-billing', $business->slug)
->with('info', sprintf(
'Plan will be downgraded to %s on %s. You\'ll continue to have access to %s features until then.',
$newPlan['name'],
$subscription->current_period_end->format('F j, Y'),
$subscription->plan_name
));
}
}
/**
* Cancel a scheduled plan downgrade.
*/
public function cancelDowngrade(Business $business)
{
$subscription = $business->subscription;
if (! $subscription || ! $subscription->hasScheduledDowngrade()) {
return redirect()
->route('seller.business.settings.plans-and-billing', $business->slug)
->with('error', 'No scheduled downgrade found.');
}
// Cancel the scheduled downgrade
$subscription->update([
'scheduled_plan_id' => null,
'scheduled_plan_name' => null,
'scheduled_plan_price' => null,
'scheduled_change_date' => null,
]);
return redirect()
->route('seller.business.settings.plans-and-billing', $business->slug)
->with('success', 'Scheduled plan downgrade has been cancelled. You will remain on your current plan.');
}
/**
* View an invoice.
*/
public function viewInvoice(Business $business, string $invoiceId)
{
// TODO: Fetch actual invoice from database
$invoice = [
'id' => $invoiceId,
'date' => now()->subDays(rand(1, 90)),
'amount' => 395.00,
'status' => 'paid',
'items' => [
['description' => 'Marketplace Business Plan', 'quantity' => 1, 'price' => 395.00],
],
];
return view('seller.settings.invoice-view', compact('business', 'invoice'));
}
/**
* Download an invoice as PDF.
*/
public function downloadInvoice(Business $business, string $invoiceId)
{
// TODO: Generate actual PDF from invoice data
// For now, return a mock PDF
$invoice = [
'id' => $invoiceId,
'date' => now()->subDays(rand(1, 90)),
'amount' => 395.00,
'status' => 'paid',
'business_name' => $business->name,
'business_address' => $business->physical_address,
];
// Generate a simple mock PDF content
$pdfContent = "INVOICE #{$invoice['id']}\n\n";
$pdfContent .= "Date: {$invoice['date']->format('m/d/Y')}\n";
$pdfContent .= "Business: {$invoice['business_name']}\n";
$pdfContent .= 'Amount: $'.number_format($invoice['amount'], 2)."\n";
$pdfContent .= 'Status: '.strtoupper($invoice['status'])."\n\n";
$pdfContent .= "This is a mock invoice for testing purposes.\n";
$pdfContent .= "In production, this would be a properly formatted PDF.\n";
return response($pdfContent, 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'attachment; filename="invoice-'.$invoiceId.'.pdf"',
]);
}
/**
* Update the notification settings.
*/
public function switchView(Request $request)
{
$validated = $request->validate([
'view' => 'required|in:sales,manufacturing,compliance',
]);
session(['current_view' => $validated['view']]);
return redirect()->back()->with('success', 'View switched successfully');
}
}