< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.WebApi.Controllers.PersonController
Assembly: ArturRios.Heimdall.WebApi
File(s): /home/ci/agent/workspace/heimdall-api-docs_main/app/src/Presentation/ArturRios.Heimdall.WebApi/Controllers/PersonController.cs
Line coverage
100%
Covered lines: 92
Uncovered lines: 0
Coverable lines: 92
Total lines: 413
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage
100%
Covered methods: 17
Fully covered methods: 17
Total methods: 17
Method coverage: 100%
Full method coverage: 100%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
CreateAdmin()100%11100%
CreateUser()100%11100%
CreateScopeOwner()100%11100%
AddScopeOwner()100%11100%
PromoteScopeUser()100%11100%
Update()100%11100%
Delete()100%11100%
HardDelete()100%11100%
RemoveScopeOwner()100%11100%
GetById()100%11100%
ListScopePersons()100%11100%
ListScopeOwners()100%11100%
ListScopeAdmins()100%11100%
ListSystemAdmins()100%11100%
SendVerificationEmail()100%11100%
SendPasswordReset()100%11100%

File(s)

/home/ci/agent/workspace/heimdall-api-docs_main/app/src/Presentation/ArturRios.Heimdall.WebApi/Controllers/PersonController.cs

#LineLine coverage
 1using ArturRios.Heimdall.Command.Input;
 2using ArturRios.Heimdall.Command.Output;
 3using ArturRios.Heimdall.Domain.Enums;
 4using ArturRios.Heimdall.Query.Input;
 5using ArturRios.Heimdall.Query.Output;
 6using ArturRios.Heimdall.Shared.Messages;
 7using ArturRios.Heimdall.WebApi.Security;
 8using ArturRios.Mediator.Command;
 9using ArturRios.Mediator.Query;
 10using ArturRios.Output;
 11using ArturRios.Util.WebApi.AspNetCore;
 12using ArturRios.Util.WebApi.Security.Attributes;
 13using Microsoft.AspNetCore.Mvc;
 14using Microsoft.AspNetCore.RateLimiting;
 15
 16namespace ArturRios.Heimdall.WebApi.Controllers;
 17
 18[Route("api")]
 29019public class PersonController(CommandMediator commandMediator, QueryMediator queryMediator) : Controller
 20{
 21    /// <summary>
 22    ///     Creates a <c>ScopeAdmin</c> or <c>SystemAdmin</c> person with no scope (UC-06 path b).
 23    ///     Restricted to System Admins (AF-06c).
 24    /// </summary>
 25    /// <remarks>
 26    ///     Derives an Argon2id hash, so it is limited to thirty requests a minute per acting person and
 27    ///     thirty per address; a rejection answers 429 "Too many requests." with a <c>Retry-After</c>
 28    ///     header.
 29    /// </remarks>
 30    [HttpPost("persons")]
 31    [EnableRateLimiting(RateLimitingConfiguration.AccountCreationRateLimitPolicy)]
 32    [RoleRequirement((int)Roles.SystemAdmin)]
 33    public async Task<ActionResult<DataOutput<CreatePersonCommandOutput?>>> CreateAdmin(
 34        [FromBody] CreateAdminCommand command)
 35    {
 7136        var result = await commandMediator
 7137            .ExecuteCommandAsync<CreateAdminCommand, CreatePersonCommandOutput>(command);
 38
 7139        return result.ToActionResult(statusMap: PersonMessageMap.StatusCodes);
 7140    }
 41
 42    /// <summary>
 43    ///     Creates a <c>User</c> within a scope (UC-06 path a). A System Admin or an owner of the scope
 44    ///     may call it; the ownership check (AF-06e) is enforced by the handler from the acting user.
 45    /// </summary>
 46    /// <remarks>
 47    ///     Derives an Argon2id hash, so it is limited to thirty requests a minute per acting person and
 48    ///     thirty per address; a rejection answers 429 "Too many requests." with a <c>Retry-After</c>
 49    ///     header.
 50    /// </remarks>
 51    [HttpPost("scopes/{scopeId:guid}/persons")]
 52    [EnableRateLimiting(RateLimitingConfiguration.AccountCreationRateLimitPolicy)]
 53    [RoleRequirement((int)Roles.SystemAdmin, (int)Roles.ScopeAdmin)]
 54    public async Task<ActionResult<DataOutput<CreatePersonCommandOutput?>>> CreateUser(
 55        Guid scopeId, [FromBody] CreateUserCommand command)
 56    {
 1257        command.ScopeId = scopeId;
 1258        HttpContext.ApplyActor(command);
 59
 1260        var result = await commandMediator
 1261            .ExecuteCommandAsync<CreateUserCommand, CreatePersonCommandOutput>(command);
 62
 1263        return result.ToActionResult(statusMap: PersonMessageMap.StatusCodes);
 1264    }
 65
 66    /// <summary>
 67    ///     Creates a brand-new <c>ScopeAdmin</c> person directly as a co-owner of a scope (UC-06 path
 68    ///     c, FR-SC-12). A System Admin or an owner of the scope may call it; the ownership check
 69    ///     (AF-06e) is enforced by the handler from the acting user.
 70    /// </summary>
 71    /// <remarks>
 72    ///     Derives an Argon2id hash, so it is limited to thirty requests a minute per acting person and
 73    ///     thirty per address; a rejection answers 429 "Too many requests." with a <c>Retry-After</c>
 74    ///     header.
 75    /// </remarks>
 76    [HttpPost("scopes/{scopeId:guid}/owners")]
 77    [EnableRateLimiting(RateLimitingConfiguration.AccountCreationRateLimitPolicy)]
 78    [RoleRequirement((int)Roles.SystemAdmin, (int)Roles.ScopeAdmin)]
 79    public async Task<ActionResult<DataOutput<CreatePersonCommandOutput?>>> CreateScopeOwner(
 80        Guid scopeId, [FromBody] CreateScopeOwnerCommand command)
 81    {
 582        command.ScopeId = scopeId;
 583        HttpContext.ApplyActor(command);
 84
 585        var result = await commandMediator
 586            .ExecuteCommandAsync<CreateScopeOwnerCommand, CreatePersonCommandOutput>(command);
 87
 588        return result.ToActionResult(statusMap: PersonMessageMap.StatusCodes);
 589    }
 90
 91    /// <summary>
 92    ///     Adds an existing <c>ScopeAdmin</c> or <c>SystemAdmin</c> person as an additional owner of a scope
 93    ///     (UC-21, FR-SC-08/FR-SC-09). A System Admin adds them at once (201, or 200 when they already own
 94    ///     it). A Scope Admin owning the scope invites them instead (202 "The invitation was sent.", FR-SC-14):
 95    ///     they own nothing until they accept (UC-60), and a repeat while it is open answers the same and
 96    ///     mails nothing. The attribute keeps a <c>User</c> out; the handler checks the scope (AF-21a), the
 97    ///     ownership (AF-21c), the person (AF-21b) and an existing ownership (AF-21d).
 98    /// </summary>
 99    /// <remarks>
 100    ///     It can mail a third party, so it shares the account-creation limit: thirty requests a minute per
 101    ///     acting person and thirty per address; a rejection answers 429 "Too many requests." with a
 102    ///     <c>Retry-After</c> header.
 103    /// </remarks>
 104    [HttpPost("scopes/{scopeId:guid}/owners/{personId:guid}")]
 105    [EnableRateLimiting(RateLimitingConfiguration.AccountCreationRateLimitPolicy)]
 106    [RoleRequirement((int)Roles.SystemAdmin, (int)Roles.ScopeAdmin)]
 107    [ProducesResponseType<DataOutput<AddScopeOwnerCommandOutput>>(StatusCodes.Status200OK)]
 108    [ProducesResponseType<DataOutput<AddScopeOwnerCommandOutput>>(StatusCodes.Status201Created)]
 109    [ProducesResponseType<DataOutput<AddScopeOwnerCommandOutput>>(StatusCodes.Status202Accepted)]
 110    public async Task<ActionResult<DataOutput<AddScopeOwnerCommandOutput?>>> AddScopeOwner(
 111        Guid scopeId, Guid personId)
 112    {
 14113        var command = new AddScopeOwnerCommand { ScopeId = scopeId, PersonId = personId };
 14114        HttpContext.ApplyActor(command);
 115
 14116        var result = await commandMediator
 14117            .ExecuteCommandAsync<AddScopeOwnerCommand, AddScopeOwnerCommandOutput>(command);
 118
 14119        return result.ToActionResult(statusMap: PersonMessageMap.StatusCodes);
 14120    }
 121
 122    /// <summary>
 123    ///     Promotes an existing <c>User</c> of a scope to <c>ScopeAdmin</c>, making them a co-owner of
 124    ///     that scope (UC-23, FR-SC-08/FR-SC-13/FR-RO-03). The attribute keeps a <c>User</c> out —
 125    ///     they can never be a System Admin nor an existing owner — while the rules that depend on
 126    ///     data it cannot see are the handler's: whether the acting Scope Admin owns this scope
 127    ///     (AF-23c), whether the scope is active (AF-23a), whether the named person is a <c>User</c>
 128    ///     of it (AF-23b), and whether they already hold the role (AF-23d).
 129    /// </summary>
 130    /// <remarks>
 131    ///     The role is written only while the person is still the <c>User</c> the request read
 132    ///     (#237): when another request changed, promoted or deleted them in the meantime, nothing is
 133    ///     written and the answer is 409 "Concurrency conflict: the record was modified or removed by
 134    ///     another process." (AF-23f), or 409 "Conflict: a record with the same unique value already
 135    ///     exists." when an administrator took the address meanwhile. Read the person again before
 136    ///     retrying.
 137    /// </remarks>
 138    [HttpPost("scopes/{scopeId:guid}/users/{personId:guid}/promote")]
 139    [RoleRequirement((int)Roles.SystemAdmin, (int)Roles.ScopeAdmin)]
 140    [ProducesResponseType<DataOutput<PromoteScopeUserCommandOutput>>(StatusCodes.Status200OK)]
 141    [ProducesResponseType<DataOutput<PromoteScopeUserCommandOutput>>(StatusCodes.Status409Conflict)]
 142    public async Task<ActionResult<DataOutput<PromoteScopeUserCommandOutput?>>> PromoteScopeUser(
 143        Guid scopeId, Guid personId)
 144    {
 12145        var command = new PromoteScopeUserCommand { ScopeId = scopeId, PersonId = personId };
 12146        HttpContext.ApplyActor(command);
 147
 12148        var result = await commandMediator
 12149            .ExecuteCommandAsync<PromoteScopeUserCommand, PromoteScopeUserCommandOutput>(command);
 150
 12151        return result.ToActionResult(statusMap: PersonMessageMap.StatusCodes);
 12152    }
 153
 154    /// <summary>
 155    ///     Updates a person's name and email, and — for a System Admin — their role (UC-08). Open to
 156    ///     any authenticated actor because a User may update their own record; the per-actor rule and
 157    ///     the role-change restriction (AF-08c) are enforced by the handler. The response's
 158    ///     <c>ownedScopeIds</c> follows FR-PE-19, as <c>GET /api/persons/{id}</c> does.
 159    /// </summary>
 160    /// <remarks>
 161    ///     A caller changing their own email presents <c>currentPassword</c>, and <c>twoFactorCode</c>
 162    ///     when their second factor is active (FR-PE-14) — six digits for an authenticator-app code,
 163    ///     or a recovery code, matched as <c>2fa/verify</c> matches one (<c>XXXX-XXXX-XXXX-XXXX</c>,
 164    ///     hyphens, spaces and case ignored, the shorter <c>XXXX-XXXX</c> codes still accepted,
 165    ///     FR-2F-20) — so this action verifies a password like the
 166    ///     credential endpoints do, and sits under their rate limit (NFR-18), as every
 167    ///     password-verifying endpoint does. An own email change mails the previous address, so it is
 168    ///     charged to the person's account-email budget once the credentials are accepted: 429 "An
 169    ///     email of this kind was sent to you recently. Try again in a few minutes." inside the
 170    ///     cooldown, 429 "You have reached the limit for account emails. Try again later." once
 171    ///     the window's five are spent (AF-08j), both with <c>Retry-After</c>; nothing changes then, and
 172    ///     no recovery or email code is spent (an accepted authenticator code's time step is recorded,
 173    ///     as for any accepted code). While the caller's email sign-in method is enabled, an own
 174    ///     email change answers 409 "Turn off the email sign-in method before changing your email
 175    ///     address." (AF-08k), once the credentials are accepted. An address or role change is
 176    ///     written only while the person still holds the address and role the request read (#237):
 177    ///     when another request changed or deleted them in the meantime, neither the change nor the
 178    ///     name is written and the answer is 409 "Concurrency conflict: the record was modified or removed by
 179    ///     another process." (AF-08l), or 409 "Conflict: a record with the same unique value already
 180    ///     exists." when the new address was taken meanwhile. As for a failed save, an email change's
 181    ///     earlier steps stand: the person's tokens are already revoked and their reset and
 182    ///     verification links retired, and for an own change the account-email budget is charged and
 183    ///     any recovery or email code presented is spent. Read the person again before retrying.
 184    /// </remarks>
 185    [HttpPut("persons/{id:guid}")]
 186    [EnableRateLimiting(RateLimitingConfiguration.AuthEndpointRateLimitPolicy)]
 187    [ProducesResponseType<DataOutput<UpdatePersonCommandOutput>>(StatusCodes.Status200OK)]
 188    [ProducesResponseType<DataOutput<UpdatePersonCommandOutput>>(StatusCodes.Status409Conflict)]
 189    public async Task<ActionResult<DataOutput<UpdatePersonCommandOutput?>>> Update(
 190        Guid id, [FromBody] UpdatePersonCommand command)
 191    {
 32192        command.Id = id;
 32193        HttpContext.ApplyActor(command);
 194
 32195        var result = await commandMediator
 32196            .ExecuteCommandAsync<UpdatePersonCommand, UpdatePersonCommandOutput>(command);
 197
 32198        return result.ToActionResult(statusMap: PersonMessageMap.StatusCodes);
 32199    }
 200
 201    /// <summary>
 202    ///     Logically deletes a person by setting <c>IsDeleted = true</c> (UC-09, FR-PE-06). The
 203    ///     attribute keeps a plain <c>User</c> out; the owner rule (AF-09c) is data-dependent and is
 204    ///     therefore enforced by the handler, as are the self-deletion (AF-09d), last-owner (AF-09e)
 205    ///     and last-System-Admin (AF-09f) refusals.
 206    /// </summary>
 207    [HttpDelete("persons/{id:guid}")]
 208    [RoleRequirement((int)Roles.SystemAdmin, (int)Roles.ScopeAdmin)]
 209    public async Task<ActionResult<DataOutput<DeletePersonCommandOutput?>>> Delete(Guid id)
 210    {
 9211        var command = new DeletePersonCommand { Id = id };
 9212        HttpContext.ApplyActor(command);
 213
 9214        var result = await commandMediator
 9215            .ExecuteCommandAsync<DeletePersonCommand, DeletePersonCommandOutput>(command);
 216
 9217        return result.ToActionResult(statusMap: PersonMessageMap.StatusCodes);
 9218    }
 219
 220    /// <summary>
 221    ///     Permanently (hard) deletes a person, removing the applications they own, their password
 222    ///     reset and email verification tokens, and their scope membership/ownership rows (UC-10,
 223    ///     FR-PE-07). Restricted to System Admins; the self-deletion refusal (AF-10c), the
 224    ///     last-owner guard (AF-10b) and the last-System-Admin guard (AF-10d) are enforced by the
 225    ///     handler.
 226    /// </summary>
 227    [HttpDelete("persons/{id:guid}/hard")]
 228    [RoleRequirement((int)Roles.SystemAdmin)]
 229    public async Task<ActionResult<DataOutput<HardDeletePersonCommandOutput?>>> HardDelete(Guid id)
 230    {
 7231        var command = new HardDeletePersonCommand { Id = id };
 7232        HttpContext.ApplyActor(command);
 233
 7234        var result = await commandMediator
 7235            .ExecuteCommandAsync<HardDeletePersonCommand, HardDeletePersonCommandOutput>(command);
 236
 7237        return result.ToActionResult(statusMap: PersonMessageMap.StatusCodes);
 7238    }
 239
 240    /// <summary>
 241    ///     Removes a person's ownership of a scope (UC-22, FR-SC-08/FR-SC-10). The attribute keeps a
 242    ///     <c>User</c> out — they can never be a System Admin nor an existing owner — while the rules
 243    ///     that depend on data it cannot see are the handler's: whether the acting Scope Admin owns
 244    ///     this scope (AF-22c), whether the scope is active and the named person actually owns it
 245    ///     (AF-22a), and whether the scope would be left without an owner (AF-22b, NFR-12).
 246    /// </summary>
 247    [HttpDelete("scopes/{scopeId:guid}/owners/{personId:guid}")]
 248    [RoleRequirement((int)Roles.SystemAdmin, (int)Roles.ScopeAdmin)]
 249    public async Task<ActionResult<DataOutput<RemoveScopeOwnerCommandOutput?>>> RemoveScopeOwner(
 250        Guid scopeId, Guid personId)
 251    {
 10252        var command = new RemoveScopeOwnerCommand { ScopeId = scopeId, PersonId = personId };
 10253        HttpContext.ApplyActor(command);
 254
 10255        var result = await commandMediator
 10256            .ExecuteCommandAsync<RemoveScopeOwnerCommand, RemoveScopeOwnerCommandOutput>(command);
 257
 10258        return result.ToActionResult(statusMap: PersonMessageMap.StatusCodes);
 10259    }
 260
 261    /// <summary>
 262    ///     Retrieves a single person by their public identifier (UC-07, FR-PE-03). Open to any
 263    ///     authenticated actor; the per-actor visibility rule (AF-07b) is data-dependent and is
 264    ///     therefore enforced by the handler. <c>ownedScopeIds</c> lists every scope the person owns
 265    ///     for a System Admin and for the person themselves; a Scope Admin reading somebody else sees
 266    ///     only the scopes they own too (FR-PE-19).
 267    /// </summary>
 268    [HttpGet("persons/{id:guid}")]
 269    public async Task<ActionResult<DataOutput<PersonOutput?>>> GetById(
 270        Guid id, [FromQuery] bool includeDeleted = false)
 271    {
 47272        var query = new GetPersonByIdQuery { Id = id, IncludeDeleted = includeDeleted };
 47273        HttpContext.ApplyActor(query);
 274
 47275        var result = await queryMediator.ExecuteQueryAsync<GetPersonByIdQuery, PersonOutput>(query, HttpContext.RequestA
 276
 47277        return result.ToActionResult(statusMap: PersonMessageMap.StatusCodes);
 47278    }
 279
 280    /// <summary>
 281    ///     Lists the <c>User</c> persons of a scope (UC-07, FR-PE-04). A System Admin or an owner of
 282    ///     the scope may call it; the ownership check (AF-07b) is enforced by the handler from the
 283    ///     acting user. <c>ownedScopeIds</c> is trimmed for a Scope Admin as on
 284    ///     <c>GET /api/persons/{id}</c> (FR-PE-19).
 285    /// </summary>
 286    [HttpGet("scopes/{scopeId:guid}/persons")]
 287    [RoleRequirement((int)Roles.SystemAdmin, (int)Roles.ScopeAdmin)]
 288    public async Task<ActionResult<PaginatedOutput<PersonOutput>>> ListScopePersons(
 289        Guid scopeId, [FromQuery] ListScopePersonsQuery query)
 290    {
 26291        query.ScopeId = scopeId;
 26292        HttpContext.ApplyActor(query);
 293
 26294        var result = await queryMediator
 26295            .ExecutePaginatedQueryAsync<ListScopePersonsQuery, PersonOutput>(query, HttpContext.RequestAborted);
 296
 26297        return result.ToActionResult(statusMap: PersonMessageMap.StatusCodes);
 26298    }
 299
 300    /// <summary>
 301    ///     Lists the owners of a scope (UC-07, FR-PE-04). A System Admin or an
 302    ///     owner of the scope may call it; the ownership check (AF-07b) is enforced by the handler
 303    ///     from the acting user. For a Scope Admin each owner's <c>ownedScopeIds</c> lists only the
 304    ///     scopes the caller owns too, this one among them; a System Admin sees them all (FR-PE-19).
 305    /// </summary>
 306    [HttpGet("scopes/{scopeId:guid}/owners")]
 307    [RoleRequirement((int)Roles.SystemAdmin, (int)Roles.ScopeAdmin)]
 308    public async Task<ActionResult<PaginatedOutput<PersonOutput>>> ListScopeOwners(
 309        Guid scopeId, [FromQuery] ListScopeOwnersQuery query)
 310    {
 7311        query.ScopeId = scopeId;
 7312        HttpContext.ApplyActor(query);
 313
 7314        var result = await queryMediator
 7315            .ExecutePaginatedQueryAsync<ListScopeOwnersQuery, PersonOutput>(query, HttpContext.RequestAborted);
 316
 7317        return result.ToActionResult(statusMap: PersonMessageMap.StatusCodes);
 7318    }
 319
 320    /// <summary>
 321    ///     Lists the administrators who may own a scope (UC-07 read d, FR-PE-12) — every
 322    ///     <c>ScopeAdmin</c>, and for a System Admin caller every <c>SystemAdmin</c> too — projected to
 323    ///     identifier, name, and email: the source for an owner picker. A System Admin or a Scope
 324    ///     Admin may call it. Optionally excludes the current owners of a named scope, in which case
 325    ///     the handler requires the caller to be entitled to manage that scope.
 326    /// </summary>
 327    [HttpGet("persons/scope-admins")]
 328    [RoleRequirement((int)Roles.SystemAdmin, (int)Roles.ScopeAdmin)]
 329    public async Task<ActionResult<PaginatedOutput<PersonSummaryOutput>>> ListScopeAdmins(
 330        [FromQuery] ListScopeAdminsQuery query)
 331    {
 8332        HttpContext.ApplyActor(query);
 333
 8334        var result = await queryMediator
 8335            .ExecutePaginatedQueryAsync<ListScopeAdminsQuery, PersonSummaryOutput>(query, HttpContext.RequestAborted);
 336
 8337        return result.ToActionResult(statusMap: PersonMessageMap.StatusCodes);
 8338    }
 339
 340    /// <summary>
 341    ///     Lists the system's <c>SystemAdmin</c> persons (UC-07 read e, FR-PE-13), projected to
 342    ///     identifier, name, and email: who else holds full access to the deployment. System Admin
 343    ///     only.
 344    /// </summary>
 345    /// <remarks>
 346    ///     A Scope Admin is deliberately excluded: a System Admin's address is not tenant-facing,
 347    ///     which is also why <c>GET /api/persons/scope-admins</c> offers System Admins only to a
 348    ///     System Admin caller.
 349    /// </remarks>
 350    [HttpGet("persons/system-admins")]
 351    [RoleRequirement((int)Roles.SystemAdmin)]
 352    public async Task<ActionResult<PaginatedOutput<PersonSummaryOutput>>> ListSystemAdmins(
 353        [FromQuery] ListSystemAdminsQuery query)
 354    {
 6355        HttpContext.ApplyActor(query);
 356
 6357        var result = await queryMediator
 6358            .ExecutePaginatedQueryAsync<ListSystemAdminsQuery, PersonSummaryOutput>(query, HttpContext.RequestAborted);
 359
 6360        return result.ToActionResult(statusMap: PersonMessageMap.StatusCodes);
 6361    }
 362
 363    /// <summary>
 364    ///     Has a fresh verification link mailed to a person's own address (UC-51, FR-EV-06): outstanding
 365    ///     links are retired first, as UC-15 does, and the link uses the scope's verification URL. A
 366    ///     System Admin may ask for any person; a Scope Admin only for a <c>User</c> of a scope they own
 367    ///     (AF-51b). No body; the token is never returned.
 368    /// </summary>
 369    /// <remarks>
 370    ///     Refused when the person is deleted or restricted (409, AF-51c) or already verified (400,
 371    ///     AF-51d), and with 429 when an email of this kind went to them within the cooldown,
 372    ///     <c>HEIMDALL_ADMIN_EMAIL_COOLDOWN_IN_SECONDS</c> (five minutes by default, AF-51e). 200 means
 373    ///     the email was handed to the sender.
 374    /// </remarks>
 375    [HttpPost("persons/{id:guid}/verification-email")]
 376    [RoleRequirement((int)Roles.SystemAdmin, (int)Roles.ScopeAdmin)]
 377    public async Task<ActionResult<DataOutput<SendPersonVerificationEmailCommandOutput?>>> SendVerificationEmail(
 378        Guid id)
 379    {
 13380        var command = new SendPersonVerificationEmailCommand { Id = id };
 13381        HttpContext.ApplyActor(command);
 382
 13383        var result = await commandMediator
 13384            .ExecuteCommandAsync<SendPersonVerificationEmailCommand, SendPersonVerificationEmailCommandOutput>(command);
 385
 13386        return result.ToActionResult(statusMap: PersonMessageMap.StatusCodes);
 13387    }
 388
 389    /// <summary>
 390    ///     Has a password-reset link mailed to a person's own address (UC-52, FR-PR-05). A System Admin
 391    ///     may ask for any person; a Scope Admin only for a <c>User</c> of a scope they own (AF-52b). No
 392    ///     body; neither a token nor a password is accepted or returned.
 393    /// </summary>
 394    /// <remarks>
 395    ///     Eligibility is UC-12's: refused (409) when the person is deleted or restricted (AF-52c) or
 396    ///     their account could not sign in — a User whose scope is deleted, a Scope Admin with no live
 397    ///     scope (AF-52d). 429 when a reset went to them within the cooldown (AF-52e). Unlike UC-12 the
 398    ///     send is not queued: the caller is authenticated and owed a real answer. 200 means the email
 399    ///     was handed to the sender.
 400    /// </remarks>
 401    [HttpPost("persons/{id:guid}/password-reset")]
 402    [RoleRequirement((int)Roles.SystemAdmin, (int)Roles.ScopeAdmin)]
 403    public async Task<ActionResult<DataOutput<SendPersonPasswordResetCommandOutput?>>> SendPasswordReset(Guid id)
 404    {
 11405        var command = new SendPersonPasswordResetCommand { Id = id };
 11406        HttpContext.ApplyActor(command);
 407
 11408        var result = await commandMediator
 11409            .ExecuteCommandAsync<SendPersonPasswordResetCommand, SendPersonPasswordResetCommandOutput>(command);
 410
 11411        return result.ToActionResult(statusMap: PersonMessageMap.StatusCodes);
 11412    }
 413}