'datetime', // Read straight into provision()'s `int $storageQuotaMb` when // the invitation is redeemed -- see the same cast on User. 'storage_quota_mb' => 'integer', ]; } /** * A fresh invitation for $email, retiring any other still-pending one * for the same address first — one live token per address at a time, * whether this is staff sending a second invite or the invited person * asking for a new link after the first expired. * * @param int $resends How many self-resends this link already stands * on. Staff leave it at zero; the resend door * passes the previous invitation's count plus * one, which is what makes the limit apply to * the chain rather than to a single row. */ public static function issue(string $email, ?string $name, ?Group $group, ?User $invitedBy, Carbon $expiresAt, int $storageQuotaMb = 0, int $resends = 0): self { self::query()->pending()->where('email', $email)->update(['status' => self::STATUS_SUPERSEDED]); return self::query()->create([ 'name' => $name, 'email' => $email, 'token' => Str::random(40), 'status' => self::STATUS_PENDING, // Zero from staff, and deliberately: sending an invitation is // somebody deciding to, which starts the allowance again. Only // a self-resend carries the previous count forward. 'resends' => $resends, 'storage_quota_mb' => $storageQuotaMb, 'group_id' => $group?->id, 'invited_by_id' => $invitedBy?->id, 'expires_at' => $expiresAt, ]); } public function isExpired(): bool { return $this->expires_at->isPast(); } /** * What a person reading a list of invitations should be told this one * is — which is not quite `status`. * * "Expired" is not a stored status and deliberately is not one: nothing * writes it, a row becomes expired by the clock passing rather than by * anybody acting, and a stored value would need a scheduled task to * stay true. But it is the distinction somebody scanning the list cares * about most, so it is derived here, once, rather than in the screen * and again in the filter — the two would eventually disagree about the * edge. * * @return 'pending'|'expired'|'redeemed'|'revoked'|'superseded' */ public function state(): string { return match ($this->status) { self::STATUS_PENDING => $this->isExpired() ? 'expired' : 'pending', self::STATUS_REDEEMED => 'redeemed', self::STATUS_REVOKED => 'revoked', default => 'superseded', }; } /** * @param Builder $query * @return Builder */ public function scopePending(Builder $query): Builder { return $query->where('status', self::STATUS_PENDING); } /** * @return BelongsTo */ public function group(): BelongsTo { return $this->belongsTo(Group::class); } /** * @return BelongsTo */ public function invitedBy(): BelongsTo { return $this->belongsTo(User::class, 'invited_by_id'); } }