Summary of completed work: - Complete buyer portal (browse, cart, checkout, orders, invoices) - Complete seller portal (orders, manifests, fleet, picking) - Business onboarding wizards (buyer 4-step, seller 5-step) - Email verification and registration flows - Notification system for buyers and sellers - Payment term surcharges and pickup/delivery workflows - Filament admin resources (Business, Brand, Product, Order, Invoice, User) - 51 migrations executed successfully Renamed Company -> Business throughout codebase for consistency. Unified authentication flows with password reset. Added audit trail and telescope for debugging. Next: Week 4 Data Migration (Days 22-28) - Product migration (883 products from cannabrands_crm) - Company migration (81 buyer companies) - User migration (preserve password hashes) - Order history migration
253 lines
6.8 KiB
PHP
253 lines
6.8 KiB
PHP
<?php
|
|
|
|
namespace App\Models;
|
|
|
|
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
|
use Database\Factories\UserFactory;
|
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
|
use Illuminate\Notifications\Notifiable;
|
|
use Spatie\Permission\Traits\HasRoles;
|
|
use Filament\Models\Contracts\FilamentUser;
|
|
use Filament\Panel;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
use Illuminate\Support\Collection;
|
|
|
|
class User extends Authenticatable implements FilamentUser
|
|
{
|
|
/** @use HasFactory<UserFactory> */
|
|
use HasFactory, Notifiable, HasRoles;
|
|
|
|
/**
|
|
* User type constants
|
|
*/
|
|
public const USER_TYPES = [
|
|
'buyer' => 'Buyer/Retailer',
|
|
'seller' => 'Seller/Brand',
|
|
'admin' => 'Super Admin'
|
|
];
|
|
|
|
/**
|
|
* The attributes that are mass assignable.
|
|
*
|
|
* @var list<string>
|
|
*/
|
|
protected $fillable = [
|
|
'first_name',
|
|
'last_name',
|
|
'email',
|
|
'password',
|
|
'phone',
|
|
'position', // Job title/role
|
|
'user_type', // admin, buyer, seller
|
|
'status', // active, inactive, suspended
|
|
'business_onboarding_completed',
|
|
'temp_business_name',
|
|
'temp_market',
|
|
'temp_contact_type',
|
|
|
|
// Contact preferences
|
|
'preferred_contact_method', // email, phone, sms
|
|
'timezone',
|
|
'language_preference',
|
|
];
|
|
|
|
/**
|
|
* The attributes that should be hidden for serialization.
|
|
*
|
|
* @var list<string>
|
|
*/
|
|
protected $hidden = [
|
|
'password',
|
|
'remember_token',
|
|
];
|
|
|
|
/**
|
|
* Get the attributes that should be cast.
|
|
*
|
|
* @return array<string, string>
|
|
*/
|
|
protected function casts(): array
|
|
{
|
|
return [
|
|
'email_verified_at' => 'datetime',
|
|
'password' => 'hashed',
|
|
'status' => 'string',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Get the user's full name by combining first and last name
|
|
* This accessor ensures Filament can display the user's name
|
|
*/
|
|
protected function name(): \Illuminate\Database\Eloquent\Casts\Attribute
|
|
{
|
|
return \Illuminate\Database\Eloquent\Casts\Attribute::make(
|
|
get: fn () => trim(($this->first_name ?? '') . ' ' . ($this->last_name ?? '')) ?: 'Unknown User'
|
|
);
|
|
}
|
|
|
|
public function canAccessPanel(Panel $panel): bool
|
|
{
|
|
if ($panel->getId() === 'admin') {
|
|
return $this->user_type === 'admin' || $this->hasRole('Super Admin');
|
|
}
|
|
|
|
if ($panel->getId() === 'seller') {
|
|
return $this->user_type === 'seller';
|
|
}
|
|
|
|
if ($panel->getId() === 'buyer') {
|
|
return $this->user_type === 'buyer';
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Primary business association for this user
|
|
* (Users can be associated with multiple businesses, but have one primary)
|
|
*/
|
|
public function primaryBusiness()
|
|
{
|
|
// First try to get explicitly primary business
|
|
$primary = $this->businesses()->wherePivot('is_primary', true)->first();
|
|
|
|
// If no primary set, return the first business
|
|
if (!$primary) {
|
|
$primary = $this->businesses()->first();
|
|
}
|
|
|
|
return $primary;
|
|
}
|
|
|
|
public function approver()
|
|
{
|
|
return $this->belongsTo(User::class, 'approved_by');
|
|
}
|
|
|
|
// Multi-tenancy methods for Filament
|
|
public function getTenants(Panel $panel): Collection
|
|
{
|
|
if ($panel->getId() === 'business') {
|
|
return $this->businesses;
|
|
}
|
|
|
|
return collect();
|
|
}
|
|
|
|
public function canAccessTenant(Model $tenant): bool
|
|
{
|
|
return $this->businesses->contains($tenant);
|
|
}
|
|
|
|
// Relationships (Business-centric approach)
|
|
public function companies(): BelongsToMany
|
|
{
|
|
return $this->belongsToMany(Business::class, 'business_user')
|
|
->withPivot('contact_type', 'is_primary', 'permissions')
|
|
->withTimestamps();
|
|
}
|
|
|
|
// Alias for backwards compatibility
|
|
public function businesses(): BelongsToMany
|
|
{
|
|
return $this->companies();
|
|
}
|
|
|
|
/**
|
|
* Contact records for this user across different businesses
|
|
*/
|
|
public function contacts(): HasMany
|
|
{
|
|
return $this->hasMany(Contact::class);
|
|
}
|
|
|
|
// Helper methods for business associations
|
|
public function isPrimaryContactFor(Business $business): bool
|
|
{
|
|
return $this->businesses()
|
|
->where('business_id', $business->id)
|
|
->wherePivot('is_primary', true)
|
|
->exists();
|
|
}
|
|
|
|
public function getRoleFor(Business $business): string
|
|
{
|
|
$pivot = $this->businesses()->where('business_id', $business->id)->first()?->pivot;
|
|
return $pivot?->contact_type ?? '';
|
|
}
|
|
|
|
public function hasContactType($contactType, Business $business = null): bool
|
|
{
|
|
if ($business) {
|
|
return $this->businesses()
|
|
->where('business_id', $business->id)
|
|
->wherePivot('contact_type', $contactType)
|
|
->exists();
|
|
}
|
|
|
|
return $this->businesses()->wherePivot('contact_type', $contactType)->exists();
|
|
}
|
|
|
|
public function canAccessBusiness(Business $business): bool
|
|
{
|
|
return $this->businesses->contains($business) &&
|
|
$business->isActive() &&
|
|
$this->isApproved();
|
|
}
|
|
|
|
public function getGroupedBusinesses(): Collection
|
|
{
|
|
return $this->businesses->flatMap(function ($business) {
|
|
return $business->getGroupedBusinesses();
|
|
})->unique('id');
|
|
}
|
|
|
|
// Status helper methods
|
|
public function isActive(): bool
|
|
{
|
|
return $this->status === 'active';
|
|
}
|
|
|
|
public function isInactive(): bool
|
|
{
|
|
return $this->status === 'inactive';
|
|
}
|
|
|
|
public function isSuspended(): bool
|
|
{
|
|
return $this->status === 'suspended';
|
|
}
|
|
|
|
/**
|
|
* Check if user's primary business is approved
|
|
*/
|
|
public function isApproved(): bool
|
|
{
|
|
$business = $this->primaryBusiness();
|
|
return $business && $business->status === 'approved';
|
|
}
|
|
|
|
/**
|
|
* Send the password reset notification with user-type-specific URL.
|
|
*
|
|
* @param string $token
|
|
* @return void
|
|
*/
|
|
public function sendPasswordResetNotification($token)
|
|
{
|
|
$routeName = match($this->user_type) {
|
|
'seller' => 'seller.password.reset',
|
|
'buyer' => 'buyer.password.reset',
|
|
default => 'buyer.password.reset', // Default to buyer for safety
|
|
};
|
|
|
|
$url = route($routeName, ['token' => $token, 'email' => $this->email]);
|
|
|
|
$this->notify(new \App\Notifications\ResetPasswordNotification($token, $url));
|
|
}
|
|
}
|