Enth\u00e4lt gemischt: Laravel-10-Upgrade + Phase 1 (Contacts-Modul, Duplicats-Commands, Soft-Delete+Merge-Fields) + Phase 2 Code-Umstellungen (inquiry_id, $table='contacts'/'inquiries') + Offers-Modul (Migrationen, Models, offer_id in Booking, offer-Disk in filesystems.php). Phase 2 + Offers werden im folgenden Commit nach dev/backups/phase2-offers-2026-04-17/ verschoben, damit der Workspace auf Phase-1-only (= Test-System-Stand) reduziert ist und direkt auf Live deploybar wird. Tarball-Backup zus\u00e4tzlich unter: ../backups-safety/workspace-pre-phase1-rollback-2026-04-17.tar.gz Made-with: Cursor
120 lines
3.3 KiB
PHP
120 lines
3.3 KiB
PHP
<?php
|
|
|
|
namespace App\Models;
|
|
|
|
use Carbon\Carbon;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
use Illuminate\Support\Str;
|
|
|
|
/**
|
|
* Kundenseitiger Zugriffstoken für /angebot/{token} (Modul 6 / Phase D).
|
|
*
|
|
* In der Datenbank wird ausschließlich der SHA-256-Hash des Klartext-
|
|
* Tokens gespeichert. Der Klartext wird einmalig bei der Erzeugung
|
|
* zurückgegeben (siehe {@see self::generate()}) und an den Kunden
|
|
* per Mail-Link ausgeliefert.
|
|
*
|
|
* Pro Angebot + Version existiert genau ein aktiver Token; wird eine
|
|
* neue Version versendet, setzt der OfferService den Vorgänger auf
|
|
* `revoked_at`.
|
|
*
|
|
* @property int $id
|
|
* @property int $offer_id
|
|
* @property int $offer_version_id
|
|
* @property string $token_hash
|
|
* @property Carbon|null $expires_at
|
|
* @property Carbon|null $first_opened_at
|
|
* @property Carbon|null $revoked_at
|
|
* @property Carbon $created_at
|
|
* @property Carbon $updated_at
|
|
* @property-read Offer $offer
|
|
* @property-read OfferVersion $version
|
|
*/
|
|
class OfferAccessToken extends Model
|
|
{
|
|
use HasFactory;
|
|
|
|
protected $table = 'offer_access_tokens';
|
|
|
|
protected $fillable = [
|
|
'offer_id',
|
|
'offer_version_id',
|
|
'token_hash',
|
|
'expires_at',
|
|
'first_opened_at',
|
|
'revoked_at',
|
|
];
|
|
|
|
protected $casts = [
|
|
'offer_id' => 'int',
|
|
'offer_version_id' => 'int',
|
|
'expires_at' => 'datetime',
|
|
'first_opened_at' => 'datetime',
|
|
'revoked_at' => 'datetime',
|
|
];
|
|
|
|
public function offer(): BelongsTo
|
|
{
|
|
return $this->belongsTo(Offer::class);
|
|
}
|
|
|
|
public function version(): BelongsTo
|
|
{
|
|
return $this->belongsTo(OfferVersion::class, 'offer_version_id');
|
|
}
|
|
|
|
public function scopeActive(Builder $q): Builder
|
|
{
|
|
return $q->whereNull('revoked_at')
|
|
->where(function (Builder $q) {
|
|
$q->whereNull('expires_at')->orWhere('expires_at', '>', now());
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Erzeugt ein neues Token für die angegebene Version und liefert
|
|
* den Klartext-Token zurück (nur einmalig abrufbar). In der
|
|
* Datenbank wird nur der Hash persistiert.
|
|
*/
|
|
public static function generate(
|
|
Offer $offer,
|
|
OfferVersion $version,
|
|
?Carbon $expiresAt = null
|
|
): array {
|
|
$plain = Str::random(48);
|
|
$hash = hash('sha256', $plain);
|
|
|
|
/** @var self $token */
|
|
$token = self::create([
|
|
'offer_id' => $offer->id,
|
|
'offer_version_id' => $version->id,
|
|
'token_hash' => $hash,
|
|
'expires_at' => $expiresAt,
|
|
]);
|
|
|
|
return ['plain' => $plain, 'token' => $token];
|
|
}
|
|
|
|
/**
|
|
* Lookup per Klartext-Token (konstantzeitig via DB-Unique-Index).
|
|
*/
|
|
public static function findByPlainToken(string $plain): ?self
|
|
{
|
|
return self::where('token_hash', hash('sha256', $plain))->first();
|
|
}
|
|
|
|
public function isActive(): bool
|
|
{
|
|
if ($this->revoked_at !== null) {
|
|
return false;
|
|
}
|
|
if ($this->expires_at !== null && $this->expires_at->isPast()) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
}
|