'int', 'offer_version_id' => 'int', 'expires_at' => 'datetime', 'first_opened_at' => 'datetime', 'revoked_at' => 'datetime', ]; /** * Transient — der ungehashte Token. Nur unmittelbar nach {@see generate()} * gesetzt, wird weder gespeichert noch serialisiert. */ public ?string $plain_token = null; // ── Beziehungen ────────────────────────────────────────────────────────── public function offer(): BelongsTo { return $this->belongsTo(Offer::class, 'offer_id'); } public function offerVersion(): BelongsTo { return $this->belongsTo(OfferVersion::class, 'offer_version_id'); } // ── Scopes ─────────────────────────────────────────────────────────────── /** * Nur Tokens, die aktuell wirklich verwendet werden können: * nicht widerrufen, nicht abgelaufen. */ public function scopeActive(Builder $query): Builder { return $query ->whereNull('revoked_at') ->where(function (Builder $q) { $q->whereNull('expires_at')->orWhere('expires_at', '>', now()); }); } // ── Factories / Helper ─────────────────────────────────────────────────── /** * Erzeugt einen neuen Access-Token für eine Version. Der Klartext-Token * wird einmalig zurückgegeben (`$result->plain_token`), die DB speichert * nur den SHA-256-Hash. * * @param OfferVersion $version * @param Carbon|null $expiresAt null → kein Ablauf (bewusst auf Caller-Ebene entscheiden) */ public static function generate(OfferVersion $version, ?Carbon $expiresAt = null): self { $plain = Str::random(self::TOKEN_LENGTH); $token = new self([ 'offer_id' => $version->offer_id, 'offer_version_id' => $version->id, 'token_hash' => hash('sha256', $plain), 'expires_at' => $expiresAt, ]); $token->save(); $token->plain_token = $plain; return $token; } /** * Findet einen aktiven Token anhand seines Klartext-Werts (Mail-Link). * Gibt null zurück, wenn der Token unbekannt, widerrufen oder abgelaufen ist. */ public static function findActiveByPlainToken(string $plain): ?self { $hash = hash('sha256', $plain); return self::query()->active()->where('token_hash', $hash)->first(); } /** * Widerruft diesen Token — er ist danach nicht mehr nutzbar. Idempotent. */ public function revoke(): void { if ($this->revoked_at === null) { $this->revoked_at = now(); $this->save(); } } }