'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; } }