20-02-2026

This commit is contained in:
Kevin Adametz 2026-02-20 17:57:50 +01:00
parent 854ce02bf6
commit 4d6b4930b2
128 changed files with 18247 additions and 2093 deletions

View file

@ -2,6 +2,7 @@
namespace App\Actions\Fortify;
use App\Enums\UserOrigin;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
@ -31,10 +32,15 @@ class CreateNewUser implements CreatesNewUsers
'password' => $this->passwordRules(),
])->validate();
$theme = config('app.theme', '');
$origin = UserOrigin::tryFrom($theme);
return User::create([
'name' => $input['name'],
'email' => $input['email'],
'password' => Hash::make($input['password']),
'origin' => $origin?->value,
'hub_id' => $input['hub_id'] ?? null,
]);
}
}

View file

@ -0,0 +1,44 @@
<?php
namespace App\Casts;
use App\Enums\PartnerType;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
class PartnerTypeCast implements CastsAttributes
{
/**
* Normalisiert DB-Werte (z.B. "retailer", "broker") zu gültigen Enum-Backing-Values.
*
* @param array<string, mixed> $attributes
*/
public function get(Model $model, string $key, mixed $value, array $attributes): ?PartnerType
{
if ($value === null || $value === '') {
return null;
}
$normalized = match (strtolower((string) $value)) {
'retailer' => PartnerType::Retailer->value,
'manufacturer' => PartnerType::Manufacturer->value,
'estate-agent', 'broker' => PartnerType::EstateAgent->value,
'customer' => PartnerType::Customer->value,
default => $value,
};
return PartnerType::tryFrom($normalized);
}
/**
* @param array<string, mixed> $attributes
*/
public function set(Model $model, string $key, mixed $value, array $attributes): ?string
{
if ($value === null) {
return null;
}
return $value instanceof PartnerType ? $value->value : (string) $value;
}
}

View file

@ -0,0 +1,28 @@
<?php
namespace App\Enums;
enum CurationStatus: string
{
case Pending = 'pending';
case Approved = 'approved';
case Rejected = 'rejected';
public function label(): string
{
return match ($this) {
self::Pending => 'Ausstehend',
self::Approved => 'Freigegeben',
self::Rejected => 'Abgelehnt',
};
}
public function color(): string
{
return match ($this) {
self::Pending => 'yellow',
self::Approved => 'green',
self::Rejected => 'red',
};
}
}

21
app/Enums/PartnerType.php Normal file
View file

@ -0,0 +1,21 @@
<?php
namespace App\Enums;
enum PartnerType: string
{
case Retailer = 'Retailer';
case Manufacturer = 'Manufacturer';
case EstateAgent = 'Estate-Agent';
case Customer = 'Customer';
public function label(): string
{
return match ($this) {
self::Retailer => 'Händler',
self::Manufacturer => 'Hersteller',
self::EstateAgent => 'Makler',
self::Customer => 'Kunde',
};
}
}

19
app/Enums/PriceType.php Normal file
View file

@ -0,0 +1,19 @@
<?php
namespace App\Enums;
enum PriceType: string
{
case Fixed = 'fixed';
case FromPrice = 'from_price';
case OnRequest = 'on_request';
public function label(): string
{
return match ($this) {
self::Fixed => 'Festpreis',
self::FromPrice => 'Ab-Preis',
self::OnRequest => 'Preis auf Anfrage',
};
}
}

View file

@ -0,0 +1,37 @@
<?php
namespace App\Enums;
enum ProductStatus: string
{
case Draft = 'draft';
case Pending = 'pending';
case Correction = 'correction';
case Active = 'active';
case Archived = 'archived';
case Sold = 'sold';
public function label(): string
{
return match ($this) {
self::Draft => 'Entwurf',
self::Pending => 'In Prüfung',
self::Correction => 'Korrektur nötig',
self::Active => 'Freigegeben',
self::Archived => 'Archiviert',
self::Sold => 'Verkauft',
};
}
public function color(): string
{
return match ($this) {
self::Draft => 'yellow',
self::Pending => 'blue',
self::Correction => 'orange',
self::Active => 'green',
self::Archived => 'zinc',
self::Sold => 'red',
};
}
}

54
app/Enums/ProductType.php Normal file
View file

@ -0,0 +1,54 @@
<?php
namespace App\Enums;
enum ProductType: string
{
/**
* Typ A Teaser-Produkte: Beratungspflicht, Ticket zwingend.
* Komplexe Konfiguration (Maße, Module, Materialien) Abschluss nur im Laden.
*/
case LocalStock = 'local_stock';
/**
* Typ B Standard-Produkte: Einfache Varianten, skalierbar, Ticket optional.
* Online vollständig konfigurierbar und direkt kaufbar.
*/
case SmartOrder = 'smart_order';
public function label(): string
{
return match ($this) {
self::LocalStock => 'Local Express (Lagerware)',
self::SmartOrder => 'Smart Club (Konfiguration)',
};
}
/**
* Typ A (LocalStock) erfordert zwingend ein Ticket für den Ladenbesuch.
* Typ B (SmartOrder) kann direkt online bestellt werden Ticket optional.
*/
public function requiresTicket(): bool
{
return match ($this) {
self::LocalStock => true,
self::SmartOrder => false,
};
}
/**
* Erlaubte Preistypen je Produkttyp.
*
* Typ A (Teaser): Kein Festpreis online möglich nur Ab-Preis oder Preis auf Anfrage.
* Typ B (Standard): Alle Preistypen erlaubt.
*
* @return array<int, PriceType>
*/
public function allowedPriceTypes(): array
{
return match ($this) {
self::LocalStock => [PriceType::FromPrice, PriceType::OnRequest],
self::SmartOrder => [PriceType::Fixed, PriceType::FromPrice, PriceType::OnRequest],
};
}
}

28
app/Enums/UserOrigin.php Normal file
View file

@ -0,0 +1,28 @@
<?php
namespace App\Enums;
enum UserOrigin: string
{
case Style2Own = 'style2own';
case StilEigentum = 'stileigentum';
public function label(): string
{
return match ($this) {
self::Style2Own => 'Style2Own',
self::StilEigentum => 'StilEigentum',
};
}
/**
* @return 'du'|'sie'
*/
public function tonality(): string
{
return match ($this) {
self::Style2Own => 'du',
self::StilEigentum => 'sie',
};
}
}

View file

@ -21,9 +21,13 @@ class BasicAuthMiddleware
if (
str_starts_with($path, 'livewire/') ||
str_starts_with($path, 'livewire-') ||
str_contains($path, '/livewire/') ||
str_contains($path, '/livewire-') ||
$request->is('livewire/*') ||
$request->is('*/livewire/*')
$request->is('livewire-*') ||
$request->is('*/livewire/*') ||
$request->is('*/livewire-*')
) {
return $next($request);
}

View file

@ -3,8 +3,20 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Attribute extends Model
{
//
protected $fillable = [
'name',
'slug',
];
/**
* Ein Attribut hat viele Werte (z.B. "Farbe" "Rot", "Blau", "Grün").
*/
public function values(): HasMany
{
return $this->hasMany(AttributeValue::class);
}
}

View file

@ -3,8 +3,30 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class AttributeValue extends Model
{
//
protected $fillable = [
'attribute_id',
'value',
'slug',
];
/**
* Gehört zu einem Attribut.
*/
public function attribute(): BelongsTo
{
return $this->belongsTo(Attribute::class);
}
/**
* Kann mehreren Produkt-Varianten zugeordnet sein.
*/
public function productVariants(): BelongsToMany
{
return $this->belongsToMany(ProductVariant::class, 'product_variant_attributes');
}
}

View file

@ -2,9 +2,44 @@
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;
class Category extends Model
{
//
use HasFactory;
protected $fillable = [
'parent_id',
'name',
'slug',
'description',
];
/**
* Übergeordnete Kategorie.
*/
public function parent(): BelongsTo
{
return $this->belongsTo(self::class, 'parent_id');
}
/**
* Untergeordnete Kategorien.
*/
public function children(): HasMany
{
return $this->hasMany(self::class, 'parent_id');
}
/**
* Produkte in dieser Kategorie.
*/
public function products(): BelongsToMany
{
return $this->belongsToMany(Product::class);
}
}

View file

@ -3,8 +3,21 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Collection extends Model
{
//
protected $fillable = [
'name',
'slug',
'description',
];
/**
* Produkte in dieser Kollektion.
*/
public function products(): HasMany
{
return $this->hasMany(Product::class);
}
}

View file

@ -33,4 +33,24 @@ class Hub extends Model
{
return $this->hasMany(Partner::class);
}
/**
* Ein Hub hat viele direkt zugeordnete User (Kunden).
*/
public function users(): HasMany
{
return $this->hasMany(User::class);
}
/**
* Ein Hub hat viele Produkte.
*/
public function products(): HasMany
{
return $this->hasMany(Product::class);
}
protected $casts = [
'is_active' => 'boolean',
];
}

36
app/Models/Media.php Normal file
View file

@ -0,0 +1,36 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class Media extends Model
{
use HasFactory;
protected $fillable = [
'model_type',
'model_id',
'file_path',
'type',
'alt_text',
'order_column',
];
protected function casts(): array
{
return [
'order_column' => 'integer',
];
}
/**
* Polymorphe Beziehung zum Eltern-Model (Product, Partner, etc.).
*/
public function model(): MorphTo
{
return $this->morphTo();
}
}

View file

@ -2,6 +2,7 @@
namespace App\Models;
use App\Casts\PartnerTypeCast;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -39,12 +40,19 @@ class Partner extends Model
'assembly_radius_km',
'provision_fixed_amount',
'provision_rate_percentage',
'story_text',
'opening_hours',
'specialties',
'founded_year',
];
protected $casts = [
'type' => PartnerTypeCast::class,
'is_active' => 'boolean',
'setup_completed' => 'boolean',
'setup_completed_at' => 'datetime',
'opening_hours' => 'array',
'specialties' => 'array',
];
/**
@ -104,9 +112,19 @@ class Partner extends Model
return $this->hasOne(Brand::class);
}
// TODO: Später die Beziehung zu Products hinzufügen
// public function products(): HasMany
// {
// return $this->hasMany(Product::class);
// }
/**
* Ein Partner hat viele Produkte.
*/
public function products(): HasMany
{
return $this->hasMany(Product::class);
}
/**
* Polymorphe Beziehung zu Media (Team-Fotos, Showroom-Galerie, etc.).
*/
public function media(): \Illuminate\Database\Eloquent\Relations\MorphMany
{
return $this->morphMany(Media::class, 'model');
}
}

246
app/Models/Product.php Normal file
View file

@ -0,0 +1,246 @@
<?php
namespace App\Models;
use App\Enums\PriceType;
use App\Enums\ProductStatus;
use App\Enums\ProductType;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
class Product extends Model
{
use HasFactory;
protected $fillable = [
'partner_id',
'partner_product_number',
'b2in_article_number',
'brand_id',
'collection_id',
'hub_id',
'name',
'slug',
'product_type',
'status',
'price_type',
'price_display_text',
'description_short',
'description_long',
'care_instructions',
'width_cm',
'height_cm',
'depth_cm',
'dimensions_specific',
'assembly_status',
'meta_title',
'meta_description',
'is_curated',
'curated_at',
'curated_by',
'curation_notes',
'is_available',
'country_of_origin',
'main_material',
'surface_material',
'cover_material',
'color_finish',
'certificates',
'assembly_time_min',
'load_capacity_kg',
'delivery_type',
'assembly_service',
'service_radius_km',
'warranty_months',
'production_time_days',
'productIsAvailable',
'visible_from',
'visible_until',
'co2_footprint_kg',
'recycling_percentage',
'is_regional_production',
'storage_volume_liters',
'assembly_effort_score',
'design_score',
];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'product_type' => ProductType::class,
'status' => ProductStatus::class,
'price_type' => PriceType::class,
'dimensions_specific' => 'array',
'is_curated' => 'boolean',
'curated_at' => 'datetime',
'is_available' => 'boolean',
'productIsAvailable' => 'boolean',
'certificates' => 'array',
'assembly_service' => 'boolean',
'is_regional_production' => 'boolean',
'visible_from' => 'date',
'visible_until' => 'date',
'co2_footprint_kg' => 'decimal:2',
];
}
/**
* Produkt gehört zu einem Partner (Händler oder Hersteller).
*/
public function partner(): BelongsTo
{
return $this->belongsTo(Partner::class);
}
/**
* Produkt gehört zu einer Marke.
*/
public function brand(): BelongsTo
{
return $this->belongsTo(Brand::class);
}
/**
* Produkt gehört zu einer Kollektion.
*/
public function collection(): BelongsTo
{
return $this->belongsTo(Collection::class);
}
/**
* Produkt gehört zu einem Hub (regionale Zuordnung).
*/
public function hub(): BelongsTo
{
return $this->belongsTo(Hub::class);
}
/**
* User, der das Produkt kuratiert/freigegeben hat.
*/
public function curator(): BelongsTo
{
return $this->belongsTo(User::class, 'curated_by');
}
/**
* Produkt kann mehreren Kategorien zugeordnet sein.
*/
public function categories(): BelongsToMany
{
return $this->belongsToMany(Category::class);
}
/**
* Produkt kann mehrere Tags haben.
*/
public function tags(): BelongsToMany
{
return $this->belongsToMany(Tag::class);
}
/**
* Produkt hat mehrere Varianten (Farben, Größen, etc.).
*/
public function variants(): HasMany
{
return $this->hasMany(ProductVariant::class);
}
/**
* Polymorphe Beziehung zu Media (Bilder, Videos, PDFs).
*/
public function media(): MorphMany
{
return $this->morphMany(Media::class, 'model');
}
/**
* Holzherkunft-Einträge für EUDR-Compliance.
*/
public function woodOrigins(): HasMany
{
return $this->hasMany(ProductWoodOrigin::class);
}
/**
* Änderungshistorie.
*/
public function activities(): HasMany
{
return $this->hasMany(ProductActivity::class);
}
/**
* Verwandte Produkte.
*/
public function relatedProducts(): BelongsToMany
{
return $this->belongsToMany(self::class, 'related_products', 'product_id', 'related_product_id');
}
/**
* Scope: Nur aktive Produkte.
*/
public function scopeActive(Builder $query): void
{
$query->where('status', ProductStatus::Active);
}
/**
* Scope: Nur kuratierte/freigegebene Produkte.
*/
public function scopeCurated(Builder $query): void
{
$query->where('is_curated', true);
}
/**
* Scope: Nur Produkte, die auf Freigabe warten.
*/
public function scopePending(Builder $query): void
{
$query->where('status', ProductStatus::Pending);
}
/**
* Scope: Nur Local Stock Produkte (Säule A).
*/
public function scopeLocalStock(Builder $query): void
{
$query->where('product_type', ProductType::LocalStock);
}
/**
* Scope: Nur Smart Order Produkte (Säule B).
*/
public function scopeSmartOrder(Builder $query): void
{
$query->where('product_type', ProductType::SmartOrder);
}
/**
* Scope: Produkte in einem bestimmten Hub.
*/
public function scopeInHub(Builder $query, int $hubId): void
{
$query->where('hub_id', $hubId);
}
/**
* Scope: Verfügbare Produkte (aktiv, kuratiert, verfügbar).
*/
public function scopeAvailable(Builder $query): void
{
$query->active()->curated()->where('is_available', true);
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ProductActivity extends Model
{
use HasFactory;
protected $fillable = [
'product_id',
'user_id',
'action',
'note',
];
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View file

@ -0,0 +1,50 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ProductLogistics extends Model
{
public $timestamps = false;
protected $fillable = [
'product_variant_id',
'shipping_class_id',
'package_width_cm',
'package_height_cm',
'package_depth_cm',
'package_weight_g',
'package_count',
'location_bin',
'packaging_type',
'packaging_recyclable_percent',
'is_palletizable',
'hs_code',
];
protected function casts(): array
{
return [
'package_count' => 'integer',
'is_palletizable' => 'boolean',
];
}
/**
* Gehört zu einer Produkt-Variante.
*/
public function productVariant(): BelongsTo
{
return $this->belongsTo(ProductVariant::class);
}
/**
* Versandklasse.
*/
public function shippingClass(): BelongsTo
{
return $this->belongsTo(ShippingClass::class);
}
}

View file

@ -3,8 +3,89 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphMany;
class ProductVariant extends Model
{
//
protected $fillable = [
'product_id',
'name_suffix',
'is_master_variant',
'sku',
'han_mpn',
'ean_gtin',
'selling_price',
'msrp',
'purchase_price',
'tax_rate_id',
'stock_quantity',
'stock_min_threshold',
'availability_status',
'delivery_time_text',
'currency',
'is_rentable',
'rental_duration_options',
'rental_rate_formula',
'residual_value_percentage',
'variant_weight_g',
'is_active',
];
protected function casts(): array
{
return [
'is_master_variant' => 'boolean',
'selling_price' => 'integer',
'msrp' => 'integer',
'purchase_price' => 'integer',
'stock_quantity' => 'integer',
'is_rentable' => 'boolean',
'rental_duration_options' => 'array',
'residual_value_percentage' => 'decimal:2',
'is_active' => 'boolean',
];
}
/**
* Gehört zu einem Produkt.
*/
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
/**
* Steuersatz der Variante.
*/
public function taxRate(): BelongsTo
{
return $this->belongsTo(TaxRate::class);
}
/**
* Attribut-Werte dieser Variante (z.B. Farbe: Rot, Größe: L).
*/
public function attributeValues(): BelongsToMany
{
return $this->belongsToMany(AttributeValue::class, 'product_variant_attributes');
}
/**
* Logistik-Daten der Variante (1:1).
*/
public function logistics(): HasOne
{
return $this->hasOne(ProductLogistics::class);
}
/**
* Bilder der Variante.
*/
public function media(): MorphMany
{
return $this->morphMany(Media::class, 'model');
}
}

View file

@ -0,0 +1,48 @@
<?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\MorphMany;
class ProductWoodOrigin extends Model
{
/** @use HasFactory<\Database\Factories\ProductWoodOriginFactory> */
use HasFactory;
protected $fillable = [
'product_id',
'wood_species',
'origin_country',
'origin_region',
'harvest_year',
'forest_operator',
'sustainability_certificate',
'eudr_reference_id',
];
protected function casts(): array
{
return [
'harvest_year' => 'integer',
];
}
/**
* Gehört zu einem Produkt.
*/
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
/**
* EUDR-Dokumente (polymorphe Beziehung via Media).
*/
public function media(): MorphMany
{
return $this->morphMany(Media::class, 'model');
}
}

55
app/Models/Setting.php Normal file
View file

@ -0,0 +1,55 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Setting extends Model
{
protected $fillable = [
'group',
'key',
'value',
'type',
'description',
];
/**
* Hole einen Setting-Wert anhand von Gruppe und Key.
*/
public static function getValue(string $group, string $key, mixed $default = null): mixed
{
$setting = self::query()
->where('group', $group)
->where('key', $key)
->first();
if (! $setting) {
return $default;
}
return match ($setting->type) {
'integer' => (int) $setting->value,
'boolean' => filter_var($setting->value, FILTER_VALIDATE_BOOLEAN),
'json' => json_decode($setting->value, true),
default => $setting->value,
};
}
/**
* Setze einen Setting-Wert.
*/
public static function setValue(string $group, string $key, mixed $value): void
{
$setting = self::query()
->where('group', $group)
->where('key', $key)
->first();
if ($setting) {
$setting->update([
'value' => is_array($value) ? json_encode($value) : (string) $value,
]);
}
}
}

View file

@ -3,8 +3,20 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class ShippingClass extends Model
{
//
protected $fillable = [
'name',
'description',
];
/**
* Logistik-Einträge mit dieser Versandklasse.
*/
public function productLogistics(): HasMany
{
return $this->hasMany(ProductLogistics::class);
}
}

View file

@ -3,8 +3,20 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Tag extends Model
{
//
protected $fillable = [
'name',
'slug',
];
/**
* Produkte mit diesem Tag.
*/
public function products(): BelongsToMany
{
return $this->belongsToMany(Product::class);
}
}

View file

@ -3,8 +3,29 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class TaxRate extends Model
{
//
protected $fillable = [
'name',
'rate_percentage',
'is_default',
];
protected function casts(): array
{
return [
'rate_percentage' => 'decimal:2',
'is_default' => 'boolean',
];
}
/**
* Varianten mit diesem Steuersatz.
*/
public function productVariants(): HasMany
{
return $this->hasMany(ProductVariant::class);
}
}

View file

@ -2,10 +2,13 @@
namespace App\Models;
use App\Enums\UserOrigin;
use App\Notifications\CustomResetPasswordNotification;
use App\Notifications\CustomVerifyEmailNotification;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
@ -13,13 +16,11 @@ use Illuminate\Support\Str;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Sanctum\HasApiTokens;
use Spatie\Permission\Traits\HasRoles;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
class User extends Authenticatable implements MustVerifyEmail
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasApiTokens, HasFactory, HasRoles, Notifiable, TwoFactorAuthenticatable, SoftDeletes;
use HasApiTokens, HasFactory, HasRoles, Notifiable, SoftDeletes, TwoFactorAuthenticatable;
/**
* The attributes that are mass assignable.
@ -28,6 +29,8 @@ class User extends Authenticatable implements MustVerifyEmail
*/
protected $fillable = [
'partner_id',
'hub_id',
'origin',
'name',
'display_name',
'email',
@ -56,6 +59,7 @@ class User extends Authenticatable implements MustVerifyEmail
'email_verified_at' => 'datetime',
'deleted_at' => 'datetime',
'password' => 'hashed',
'origin' => UserOrigin::class,
];
}
@ -64,6 +68,14 @@ class User extends Authenticatable implements MustVerifyEmail
return $this->belongsTo(Partner::class);
}
/**
* Direkte Hub-Zuordnung des Users (für schnelle Queries).
*/
public function hub(): BelongsTo
{
return $this->belongsTo(Hub::class);
}
/**
* Get the registration code used by this user
*/
@ -79,7 +91,7 @@ class User extends Authenticatable implements MustVerifyEmail
{
return Str::of($this->name)
->explode(' ')
->map(fn(string $name) => Str::of($name)->substr(0, 1))
->map(fn (string $name) => Str::of($name)->substr(0, 1))
->implode('');
}
@ -89,9 +101,9 @@ class User extends Authenticatable implements MustVerifyEmail
public function anonymize(): void
{
$this->update([
'name' => 'Gelöschter Benutzer #' . $this->id,
'name' => 'Gelöschter Benutzer #'.$this->id,
'display_name' => null,
'email' => 'deleted_' . $this->id . '@anonymized.local',
'email' => 'deleted_'.$this->id.'@anonymized.local',
'password' => bcrypt(Str::random(64)),
]);
@ -118,7 +130,6 @@ class User extends Authenticatable implements MustVerifyEmail
* Send the password reset notification.
*
* @param string $token
* @return void
*/
public function sendPasswordResetNotification($token): void
{
@ -127,8 +138,6 @@ class User extends Authenticatable implements MustVerifyEmail
/**
* Send the email verification notification.
*
* @return void
*/
public function sendEmailVerificationNotification(): void
{

View file

@ -0,0 +1,67 @@
<?php
namespace App\Policies;
use App\Models\Partner;
use App\Models\User;
class PartnerPolicy
{
/**
* Admins und Super-Admins können alle Partner sehen.
* Partner können nur ihren eigenen Eintrag sehen.
*/
public function viewAny(User $user): bool
{
return $user->hasAnyRole(['Admin', 'Super-Admin']);
}
/**
* Admins sehen alle Partner; Partner sehen ihren eigenen Eintrag.
*/
public function view(User $user, Partner $partner): bool
{
if ($user->hasAnyRole(['Admin', 'Super-Admin'])) {
return true;
}
return $user->partner_id === $partner->id;
}
/**
* Nur Admins können neue Partner direkt anlegen (normale Partner werden eingeladen).
*/
public function create(User $user): bool
{
return $user->hasAnyRole(['Admin', 'Super-Admin']);
}
/**
* Admins können jeden Partner bearbeiten.
* Partner können ihr eigenes Profil bearbeiten.
*/
public function update(User $user, Partner $partner): bool
{
if ($user->hasAnyRole(['Admin', 'Super-Admin'])) {
return true;
}
return $user->partner_id === $partner->id;
}
/**
* Nur Admins können Partner löschen.
*/
public function delete(User $user, Partner $partner): bool
{
return $user->hasAnyRole(['Admin', 'Super-Admin']);
}
/**
* Produkte eines Partners kuratieren (freigeben/ablehnen).
*/
public function curateProducts(User $user): bool
{
return $user->hasPermissionTo('curate products');
}
}

View file

@ -0,0 +1,73 @@
<?php
namespace App\Policies;
use App\Models\Product;
use App\Models\User;
class ProductPolicy
{
/**
* Admins, Retailer und Manufacturer können Produkte sehen.
*/
public function viewAny(User $user): bool
{
return $user->hasAnyRole(['Admin', 'Super-Admin', 'Retailer', 'Manufacturer']);
}
/**
* Admins sehen alle Produkte; Partner sehen nur ihre eigenen.
*/
public function view(User $user, Product $product): bool
{
if ($user->hasAnyRole(['Admin', 'Super-Admin'])) {
return true;
}
return $product->partner_id === $user->partner_id;
}
/**
* Retailer dürfen Teaser-Produkte (Typ A) anlegen.
* Manufacturer dürfen Konfigurations-Produkte (Typ B) anlegen.
* Admins dürfen beide Typen anlegen.
*/
public function create(User $user): bool
{
return $user->hasAnyRole(['Admin', 'Super-Admin', 'Retailer', 'Manufacturer']);
}
/**
* Admins können alle Produkte bearbeiten.
* Partner können nur ihre eigenen Produkte bearbeiten.
*/
public function update(User $user, Product $product): bool
{
if ($user->hasAnyRole(['Admin', 'Super-Admin'])) {
return true;
}
return $product->partner_id === $user->partner_id;
}
/**
* Admins können alle Produkte löschen.
* Partner können nur ihre eigenen Produkte löschen.
*/
public function delete(User $user, Product $product): bool
{
if ($user->hasAnyRole(['Admin', 'Super-Admin'])) {
return true;
}
return $product->partner_id === $user->partner_id;
}
/**
* Nur Admins können Produkte kuratieren (freigeben/ablehnen).
*/
public function curate(User $user): bool
{
return $user->hasPermissionTo('curate products');
}
}