Files
ignacionelson 62c763d04e Put a floor under a client quota nobody set
Setting::DefaultClientStorageQuotaMb defaults to 0, and 0 means
unlimited. That is the right default for somebody setting up their own
installation and the wrong one for an installation a platform operates
on other people's behalf: an account that arrived without an explicit
quota has no ceiling at all, and it does not have to be an account the
platform created.

So a platform may set a floor in the environment
(PROJECTSEND_PLATFORM_DEFAULT_CLIENT_QUOTA_MB), exactly as it sets the
seat caps, and for the same reason those are not settings: it is the
shape of what was sold rather than a preference the installation's
administrator is expressing. It applies only where the setting says
nothing, so an administrator who chose a number keeps it, and an install
with no platform behind it is unaffected.

ClientStorageUsage::defaultQuotaMb() is where the three sources resolve,
and every screen that presents the answer now reads it there:

  - The client create and edit screens. The edit screen mirrors that
    resolution client-side to draw the usage bar, so handed the raw
    setting on a floored installation it computed an effective quota of
    zero, printed "unlimited" and hid the bar entirely -- for a client
    whose next upload was about to be rejected for exceeding a limit the
    screen said did not exist.

  - projectsend:status, which gains clients_can_register and
    default_client_storage_quota_mb. Both defaults are the permissive
    ones, both are invisible from outside, and a document reporting the
    setting while uploads obeyed the floor would say the ceiling was
    missing on an installation that has one.

The Client settings form deliberately still reads the raw setting: that
field is read and written back on save, so prefilling it with the floor
would write the platform's number into the setting as the
administrator's own choice, where it would outlive the floor.
2026-09-11 00:41:31 -03:00

695 lines
31 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Platform\Installation\Console;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLog;
use App\Modules\Clients\ClientStorageUsage;
use App\Modules\Files\Models\File;
use App\Modules\Identity\TwoFactor\TwoFactorEnforcement;
use App\Modules\Identity\UserType;
use App\Modules\Platform\Capabilities\CapabilityRegistry;
use App\Modules\Platform\Installation\Events\ResolvingInstallationStatus;
use App\Modules\Platform\Scheduling\ScheduledTaskRun;
use App\Modules\Platform\Scheduling\TaskRunStatus;
use App\Modules\Platform\Seats\SeatAllowance;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Console\Command;
use Illuminate\Database\Migrations\Migrator;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Queue;
use Throwable;
/**
* What this installation is, as a fact rather than a screen.
*
* Written for whatever watches a managed installation from outside the
* container. Everything here is already visible to any signed-in
* administrator — a version, an edition, which capabilities the edition
* grants, how many accounts exist against how many are allowed. Nothing
* is a secret and nothing is a credential.
*
* ### Why a command and not a shell one-liner
*
* A reconciler that observes tenants has to be able to say it never sends
* instructions, only reads state. `docker exec … php -r '…'` is an
* instruction with the caller's argv in it, however harmless the argv;
* a named command is an observation, the same kind of thing as reading a
* directory size. The distinction is the whole reason this exists rather
* than a documented incantation.
*
* ### The counts are the enforcing code's own
*
* `used` comes from SeatAllowance, which is what refuses the account past
* the limit. Two counts that merely agree will diverge eventually — over
* an inactive account, or a soft-deleted one — and the divergence looks
* like a billing fault rather than a counting one. So there is one
* definition and this reads it.
*
* ### Is anybody there
*
* `activity.last_staff_login_at` answers the one question a platform
* cannot answer from outside: whether a human still uses this
* installation. It is a timestamp and nothing else — no name, no address,
* no session. Only interactive sign-ins reach it, because that is all
* Laravel's Login event fires for: an integration polling the API every
* hour must not make a dormant installation look busy.
*
* Derived from the activity log rather than denormalised onto `users`. A
* column would need a migration, a listener change and a backfill to save
* one indexed MAX() over a table that is small on exactly the
* installations anybody asks this about. The log is never pruned, and
* erasure anonymises entries rather than deleting them (`actor_type`
* survives on purpose — see AccountEraser), so the answer does not change
* when the person who gave it is forgotten.
*
* **That "never pruned" is now somebody's safety argument.** The hosted
* platform warns, pauses and finally removes a free instance nobody has
* signed in to, and this field is what it counts from. Retention or
* pruning added to `activity_log` would not break anything here — it
* would quietly make old installations look dormant, and the thing that
* acts on that reading deletes them. Anyone adding it needs to give this
* field another source first, not merely check that the tests still
* pass.
*
* ### Storage is the application's number, not the disk's
*
* `storage.bytes` is what this installation holds, summed from the rows
* that record it. Measuring the directory instead was correct until
* external storage went live, and silently stopped being: an upload that
* resolves to a bucket leaves nothing on the volume to measure, so a
* figure taken from the filesystem freezes while the account keeps
* filling. `by_disk` is the same sum split by where the bytes went, which
* is the only way to see what is still sitting on local disk from before
* a cutover.
*
* Trashed files are excluded because they hold no bytes: File's `deleted`
* hook removes them, so a soft-deleted row is a record of something that
* is gone rather than something still costing anything.
*
* ### Health is what a container cannot show from outside
*
* A tenant's queue worker dying is invisible to anything watching the
* container: it is still up, and zips quietly stop building while mail
* stops going out. Same for migrations that failed after a deploy — the
* application answers every request and is a schema behind. Neither is a
* secret; both are already visible to anyone who can open the database,
* which is anyone who can run this command.
*
* ### What core cannot answer
*
* `modules` is filled by whatever packages are installed, through
* ResolvingInstallationStatus. A platform that provisioned a bucket knows
* what it asked for; only the installation knows what loaded.
*
* ### `capabilities` is compared, not displayed
*
* The control plane reads this list against the plan it wrote for the
* tenant — "this instance is on the free plan and still grants branding"
* is a comparison, not a glance. So the *keys* and their order are a
* contract: renaming one, or reordering the enum they come from, breaks
* that comparison while every test here keeps passing. A key that changes
* meaning needs a new key, not an edit.
*
* ### `usage` and `health.scheduler` are charted, so their keys are a promise
*
* The hosted platform's customer dashboard plots these over time. That
* makes the key names a contract in the same way `capabilities` is one,
* and it fails in a nastier way: a renamed capability key breaks a
* comparison that somebody is watching, while a renamed `usage` key
* produces a chart that is silently *empty* rather than an error. Nobody
* gets paged for a flat line.
*
* So: add keys freely, and never rename or repurpose one. A key whose
* meaning changes needs a new key, not a new value — "downloads" that
* quietly starts excluding staff is worse than "downloads" disappearing,
* because the second is noticed.
*
* `usage` is a **rolling window, deliberately, and has no lifetime
* totals**. Not a presentation choice: `activity_log` is never pruned
* (see above), so a lifetime count over it gets slower every day of the
* installation's life, while a windowed one stays flat forever. The
* window is stated in the document as `window_days` rather than assumed
* by the reader, so changing it is visible to whoever is plotting it.
*
* Every count here rides one of the two composite indexes added for it —
* see the migration adding them, which also records why they have to
* ship as a pair.
*
* ### The scheduler is the one thing nothing else can see
*
* `health.queues` catches a dead worker. Nothing catches a dead
* *scheduler*, and its symptom is not a stalled feature: expired files
* stop being purged, so content that was supposed to become unreachable
* stays reachable, and orphans and stale uploads accumulate against a
* quota nobody is watching. The installation looks completely healthy
* while it happens, to its operator and to its administrator alike.
*
* ### `settings` holds the three an outsider has to act on, and no more
*
* Not a dump of the settings table. Everything here leaves the container,
* so each field needs a reason somebody outside would act on it, and
* these three have one: they are the settings whose wrong value is
* invisible from outside and expensive.
*
* `two_factor_enforcement` is what the platform sold compared against
* what the installation applied. The other two are one question in two
* halves -- **who can make an account here, and what that account is
* allowed** -- and the answer matters most where it is least visible. On
* a shared installation every client is a separate customer, so
* self-registration switched on means accounts appearing that nobody
* provisioned, and a default quota of zero means those accounts have no
* ceiling at all. Both defaults are the permissive ones (see Setting),
* which is right for a self-hosted install setting itself up and wrong
* for an installation somebody else is operating.
*
* Neither is a secret: both are on the client settings screen any
* administrator can open. What the document adds is that a watcher can
* see them without one.
*
* ### A version is a decision, a commit is a fact
*
* `build` says which commit this installation was built from. A version
* string is chosen by somebody and stamped; two images can carry the same
* one and different code — an image built from the tag, and one built
* from the branch that tag sits on. A fleet spent a day reporting "2.2.0"
* from images that were not the released 2.2.0, and nothing inside any of
* them could have said so.
*
* Null on a source checkout, all four fields, because `config/build.php`
* is written by build-release.sh and a checkout is not a build. That is
* the honest answer rather than a missing one: "I was not built" and "I
* will not say" are different, and only the first is true here.
*/
class StatusCommand extends Command
{
/**
* The rolling window every `usage` count is measured over.
*
* Emitted in the document as `window_days` rather than left for the
* reader to know, because a number that is charted and a number that
* is assumed diverge exactly once and silently.
*/
private const USAGE_WINDOW_DAYS = 30;
/**
* The actions `usage.actions` counts, and the whole of it.
*
* An allowlist rather than a `group by action`, for two reasons that
* happen to agree. Privacy: this document leaves the installation, and
* cases land in Action most weeks — an open group-by would start
* shipping new action names outward with nobody having decided that
* they should go, and some of them (`account.erased`,
* `two_factor.reset`, `password.updated`) are somebody's compliance
* event rather than a business metric. Cost: five keyed counts measure
* ~30x cheaper than one `group by action` over the same window,
* because each rides (action, created_at) while the group-by starts
* from created_at and reads rows.
*
* These five answer "is my library growing, are people being added, is
* anything being shared" and nothing else. Uploads and downloads are
* their own fields; none of these names a person.
*
* @var list<Action>
*/
private const USAGE_ACTIONS = [
Action::UserCreated,
Action::ClientSelfRegistered,
Action::FileAssigned,
Action::ShareLinkCreated,
Action::GroupCreated,
];
protected $signature = 'projectsend:status {--json : Emit machine-readable JSON on stdout}';
protected $description = 'Report this installation\'s version, edition, capabilities and seat usage';
public function handle(
CapabilityRegistry $capabilities,
SeatAllowance $seats,
Settings $settings,
ClientStorageUsage $quotas,
): int {
$status = [
'version' => (string) config('projectsend.version'),
'edition' => $capabilities->edition()->value,
'capabilities' => $capabilities->enabledKeys(),
'seats' => [
'staff' => [
'used' => $seats->staffUsed(),
// null is unlimited, and is emitted as null rather than
// as 0 or as an absent key: a reader that mistook one
// for the other would report an installation selling
// unlimited accounts as one that may hold none.
'limit' => $seats->staffLimit(),
],
'clients' => [
'used' => $seats->clientUsed(),
'limit' => $seats->clientLimit(),
],
],
'activity' => [
// Null means "no staff account has ever signed in here",
// and is emitted rather than left out for the same reason
// an unlimited seat count is: a watcher has to be able to
// tell that apart from "we got no answer". Collapsing the
// two is how a broken probe reads as a dormant fleet.
'last_staff_login_at' => $this->lastLoginAt(UserType::Staff),
// The staff timestamp says the administrator still shows
// up. This one says their customers do, which is a
// different question and the more interesting half: an
// installation whose only visitor is the person paying
// for it is one nobody is getting value from.
'last_client_login_at' => $this->lastLoginAt(UserType::Client),
],
'storage' => $this->storage(),
'usage' => $this->usage(),
'health' => $this->health(),
'settings' => [
// Echoed back rather than assumed: an operator writes the
// environment variable, and this is the installation
// saying what it actually applied. Read the way
// EnforceTwoFactor reads it, down to what an unreadable
// value falls back to -- reporting a stricter answer than
// the middleware enforces would be worse than reporting
// none at all.
'two_factor_enforcement' => $this->enforcement($settings),
// Whether strangers can make themselves an account here.
// Read the way RegistrationController reads it, `=== true`
// included: `get()` casts a boolean with `(bool)`, so the
// only value that is neither true nor false is null, and
// null is what the gate treats as closed.
'clients_can_register' => $settings->get(Setting::ClientsCanRegister) === true,
// What a client with no quota of their own is allowed, in
// megabytes. **Zero means unlimited**, which is the whole
// reason this is worth reporting: it is the default, it is
// the answer for every account created without one asked
// for, and nothing else in this document reveals it.
//
// The *effective* number, through the same method the
// upload check resolves it with, rather than the setting
// read on its own. A platform can put a floor under it from
// the environment, and a document that reported the setting
// while the uploads obeyed the floor would say the ceiling
// was missing on an installation that has one.
'default_client_storage_quota_mb' => $quotas->defaultQuotaMb(),
],
// Cast so an installation with no packages emits {} rather
// than [] -- an empty PHP array encodes as a list, and a
// reader unmarshalling a map breaks on the day it happens to
// be empty rather than on the day it is written.
'modules' => (object) $this->modules(),
'build' => [
// `channel` is 'release' or 'dev'. An internal build names
// itself after its commit and can never be published, so a
// fleet reading 'dev' is looking at something deliberate
// rather than at a mistake.
'commit' => $this->buildFact('commit'),
'ref' => $this->buildFact('ref'),
'channel' => $this->buildFact('channel'),
'built_at' => $this->buildFact('built_at'),
],
];
if ($this->option('json')) {
$this->line((string) json_encode($status, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
return self::SUCCESS;
}
$this->line("ProjectSend {$status['version']} ({$status['edition']})");
$this->line('Capabilities: '.(implode(', ', $status['capabilities']) ?: 'none'));
$this->line('Staff seats: '.$this->seatLine($status['seats']['staff']));
$this->line('Clients: '.$this->seatLine($status['seats']['clients']));
$this->line('Last staff login: '.($status['activity']['last_staff_login_at'] ?? 'never'));
$this->line('Storage: '.number_format($status['storage']['bytes']).' bytes in '.$status['storage']['files'].' files');
$this->line('Build: '.($status['build']['ref'] ?? 'not a build')
.($status['build']['channel'] === 'dev' ? ' (dev)' : ''));
$this->line('Health: '.$status['health']['pending_migrations'].' migrations pending, '
.$status['health']['failed_jobs'].' failed jobs, '
.array_sum(array_filter($status['health']['queues'], 'is_int')).' queued');
$this->line('Scheduler: '.($status['health']['scheduler']['last_run_at'] ?? 'never run')
.' ('.$status['health']['scheduler']['failing'].' failing)');
$this->line('Last '.self::USAGE_WINDOW_DAYS.'d: '
.array_sum($status['usage']['downloads']).' downloads, '
.$status['usage']['uploads'].' uploads');
return self::SUCCESS;
}
private function buildFact(string $key): ?string
{
$value = config("build.$key");
return is_string($value) && $value !== '' ? $value : null;
}
private function enforcement(Settings $settings): string
{
$value = $settings->get(Setting::TwoFactorEnforcement);
$enforcement = (is_string($value) ? TwoFactorEnforcement::tryFrom($value) : null)
?? TwoFactorEnforcement::None;
return $enforcement->value;
}
/**
* What this installation holds, from the rows that record it.
*
* @return array{bytes: int, files: int, by_disk: object}
*/
private function storage(): array
{
$perDisk = File::query()
->groupBy('disk')
->selectRaw('disk, sum(size) as bytes, count(*) as files')
->get();
return [
'bytes' => (int) $perDisk->sum(fn (File $row): int => (int) $row->getAttribute('bytes')),
'files' => (int) $perDisk->sum(fn (File $row): int => (int) $row->getAttribute('files')),
// Keyed by disk name rather than a list, because the reader
// wants one of them by name — "how much is still local" — and
// not to walk a list looking for it.
// Same reason as `modules`: an installation holding no files
// at all must still answer with a map.
'by_disk' => (object) $perDisk
->mapWithKeys(fn (File $row): array => [
(string) $row->getAttribute('disk') => [
'bytes' => (int) $row->getAttribute('bytes'),
'files' => (int) $row->getAttribute('files'),
],
])->all(),
];
}
/**
* @return array{pending_migrations: int, failed_jobs: int, failed_jobs_latest_at: string|null, queues: array<string, int|null>, scheduler: array{last_run_at: string|null, failing: int}}
*/
private function health(): array
{
return [
'pending_migrations' => $this->pendingMigrations(),
'failed_jobs' => $this->failedJobs(),
'failed_jobs_latest_at' => $this->latestFailureAt(),
// The two this application actually runs workers for. A depth
// is not a fault on its own -- a busy installation has one --
// but a depth that only ever grows is a worker that died, and
// nothing outside the container can see the difference.
'queues' => [
'default' => $this->queueDepth('default'),
'zips' => $this->queueDepth('zips'),
],
'scheduler' => $this->scheduler(),
];
}
/**
* Whether the scheduler is running, and whether what it runs works.
*
* One row per known command, upserted on every run, so this is a
* dozen rows however old the installation is.
*
* `last_run_at` is null when nothing has ever run — a brand new
* installation, or one whose scheduler has never been wired up at
* all — and those are different from "ran, a long time ago", which
* is a timestamp. The reader decides what counts as too old; every
* task in routes/console.php is daily, so anything past about a day
* means nobody is running it. Deliberately not judged here: a
* threshold belongs to whoever is watching, and baking one in would
* make the answer wrong for anyone whose schedule is not ours.
*
* The failure *message* is deliberately not reported. This document
* leaves the installation, and a task's error text is the one field
* here that can carry a filesystem path, a hostname or an exception
* from somebody's storage backend. A count says "go and look",
* which is all a watcher needs and all it is owed.
*
* `failing` counts commands whose *most recent* run failed, not
* failures over time — the row is upserted, so a task that failed
* last night and succeeded this morning is not failing. A task that
* has never run is not counted here either; it is absent from the
* table, which is what `last_run_at` is for.
*
* @return array{last_run_at: string|null, failing: int}
*/
private function scheduler(): array
{
$lastRun = ScheduledTaskRun::query()->max('ran_at');
return [
'last_run_at' => $lastRun === null ? null : Carbon::parse($lastRun)->toIso8601String(),
'failing' => ScheduledTaskRun::query()
->where('status', TaskRunStatus::Failed)
->count(),
];
}
/**
* What has been happening here lately.
*
* Every figure is a count over the same rolling window and there are
* no lifetime totals — see the class docblock for why that is a
* correctness decision rather than a presentational one.
*
* Downloads are split the way the installation's own dashboard
* splits them (DashboardController::transferSeries), on purpose: the
* administrator and whatever is reading this document have to be able
* to agree about a number they can both see. Staff downloads are
* reported rather than dropped so a reader can choose, but they are
* their own key precisely because they are not audience traffic — an
* administrator opening their own upload to check it is not somebody
* receiving a file.
*
* @return array{window_days: int, downloads: array{staff: int, clients: int, anonymous: int}, uploads: int, actions: object}
*/
private function usage(): array
{
$since = now()->subDays(self::USAGE_WINDOW_DAYS);
$downloads = [
Action::FileDownloaded->value,
Action::ShareLinkDownloaded->value,
Action::PublicFileDownloaded->value,
];
return [
'window_days' => self::USAGE_WINDOW_DAYS,
'downloads' => [
'staff' => $this->countActionsByActor($downloads, $since, UserType::Staff->value),
'clients' => $this->countActionsByActor($downloads, $since, UserType::Client->value),
// Null actor_type is the anonymous case: a share link or
// the public listing, served to somebody with no account
// at all. It is the traffic an administrator has no other
// way to see.
'anonymous' => $this->countActionsByActor($downloads, $since, null),
],
'uploads' => $this->countActions([Action::FileUploaded->value], $since),
// Cast for the reason `modules` is: an empty PHP array
// encodes as a list, and a reader unmarshalling a map breaks
// on the day it happens to be empty rather than on the day it
// is written. It cannot be empty today, but the allowlist is
// meant to be edited.
//
// What that costs, confirmed against the reader rather than
// guessed at: the hosted platform's probe decodes this block
// into a typed struct and discards a block it cannot read, and
// Go refuses a JSON list into a map outright. So a `[]` here
// would not lose `actions` — it would lose the whole `usage`
// block, downloads and uploads with it, on the day a tenant
// happened to have no counted activity. The quietest
// installations would stop reporting and nothing would log a
// fault. Both shapes are pinned by tests on that side too.
'actions' => (object) $this->usageActions($since),
];
}
/**
* @return array<string, int>
*/
private function usageActions(Carbon $since): array
{
$counts = [];
// One keyed count each rather than a single grouped query: this
// is both the cheaper shape (each rides (action, created_at);
// a group-by starts from created_at and reads rows) and the one
// that can only ever emit keys somebody chose. See USAGE_ACTIONS.
foreach (self::USAGE_ACTIONS as $action) {
$counts[$action->value] = $this->countActions([$action->value], $since);
}
return $counts;
}
/**
* How many of these actions happened in the window, by anyone.
*
* @param list<string> $actions
*/
private function countActions(array $actions, Carbon $since): int
{
return ActivityLog::query()
->whereIn('action', $actions)
->where('created_at', '>=', $since)
->count();
}
/**
* The same count, narrowed to one kind of actor.
*
* Separate from countActions() rather than an optional argument on
* it, because the argument would have to carry three states — staff,
* client, and *nobody at all* — and null already means the third.
* An optional `?string $actorType = null` reads as "no filter" at
* every call site and would have silently reported the installation's
* whole download total in the anonymous column.
*
* @param list<string> $actions
* @param string|null $actorType null is the anonymous case: a share
* link or the public listing, served
* to somebody with no account
*/
private function countActionsByActor(array $actions, Carbon $since, ?string $actorType): int
{
$query = ActivityLog::query()
->whereIn('action', $actions)
->where('created_at', '>=', $since);
return ($actorType === null
? $query->whereNull('actor_type')
: $query->where('actor_type', $actorType)
)->count();
}
private function pendingMigrations(): int
{
/** @var Migrator $migrator */
$migrator = app('migrator');
// Every path, not just database/migrations: a package registers
// its own, and a package migration left unrun is exactly the kind
// of half-deploy this is here to report.
$files = $migrator->getMigrationFiles(array_merge([database_path('migrations')], $migrator->paths()));
return count(array_diff(array_keys($files), $migrator->getRepository()->getRan()));
}
private function failedJobs(): int
{
$table = config('queue.failed.table');
if (! is_string($table) || $table === '') {
return 0;
}
return DB::table($table)->count();
}
/**
* When the most recent job failed, or null if none has.
*
* `failed_jobs` on its own cannot answer whether anything is wrong
* *now*, and reading it as though it could is a category error rather
* than a threshold that needs tuning. It is a history: the table is
* swept daily by projectsend:purge-failed-jobs, so the count spans a
* retention window — one whose length the installation chooses on the
* Scheduler Monitoring screen, and which can be set to 0 for "keep
* forever" by somebody who treats a failed job as evidence rather
* than as debris.
*
* So the same number means different things on two identical
* installations, and on a keep-forever one it grows without bound
* until any fixed threshold trips. A fleet comparing tenants on the
* count alone is comparing their retention settings.
*
* This is the field that answers the question actually being asked —
* "has anything failed lately" — because a timestamp is independent
* of how long the rows are kept. A count of 27 whose newest entry is
* three weeks old is an installation that has been healthy for three
* weeks and has not been swept yet.
*
* The exception text stays out, for the reason the scheduler's
* message does: it carries paths, hostnames and stack traces, and
* this document leaves the installation.
*/
private function latestFailureAt(): ?string
{
$table = config('queue.failed.table');
if (! is_string($table) || $table === '') {
return null;
}
$latest = DB::table($table)->max('failed_at');
return $latest === null ? null : Carbon::parse($latest)->toIso8601String();
}
/**
* Null rather than a crash when the queue cannot be reached, and null
* rather than zero: an unreachable Redis is not an empty queue, and a
* reader watching for a worker that died would read the second as
* everything being fine.
*
* This command is a probe, and a probe that dies on one unreachable
* dependency tells the reader nothing about the facts it could still
* have answered.
*/
private function queueDepth(string $queue): ?int
{
try {
return Queue::size($queue);
} catch (Throwable) {
return null;
}
}
/**
* @return array<string, string|int|bool|null>
*/
private function modules(): array
{
$event = new ResolvingInstallationStatus;
Event::dispatch($event);
return $event->facts;
}
/**
* The most recent interactive sign-in by this kind of account, or
* null if there has never been one.
*/
private function lastLoginAt(UserType $type): ?string
{
$latest = ActivityLog::query()
->where('action', Action::Login->value)
->where('actor_type', $type->value)
->max('created_at');
// Answered out of (action, actor_type, created_at) without
// reading a row: the two equalities are that index's prefix and
// the MAX is the last entry under them. Before that index existed
// this was a scan of every login the installation had ever
// recorded, with a primary-key lookup per row to check the actor.
return $latest === null ? null : Carbon::parse($latest)->toIso8601String();
}
/**
* @param array{used: int, limit: int|null} $seat
*/
private function seatLine(array $seat): string
{
return $seat['limit'] === null
? "{$seat['used']} of unlimited"
: "{$seat['used']} of {$seat['limit']}";
}
}