refinement phase 2
This commit is contained in:
+5
-2
@@ -575,8 +575,11 @@ action to `sender.Send(...)`. Full conventions are in [CONVENTIONS.md](CONVENTIO
|
||||
- **Development demo seeder (refinement-phase-1).** `Persistence/Services/Seeding/DemoWorldSeeder.cs`
|
||||
(+ `DemoWorldDefinitions.cs`) idempotently populates a coherent demo marketplace on top of the reference
|
||||
`HasData` seeds — 3 nurses (2 verified w/ variants + Tehran coverage + `approved` verification + credentials
|
||||
+ a `matched_national_id` bank account, 1 unverified), 2 customers (patients + addresses), and one
|
||||
cross-category required demo option group (شیفت / *Shift Type*). It writes through the real entities and
|
||||
+ a `matched_national_id` bank account, 1 unverified), 2 customers (patients + addresses), **2 phone-OTP
|
||||
admins** (refinement-phase-2: a `super_admin` + a scoped `finance` operator, so the `/admin` console is
|
||||
reachable through the normal phone-OTP login and `useAdminCapabilities` gating is demonstrable — admin
|
||||
sub-roles are server-granted, never self-selectable), and one cross-category required demo option group
|
||||
(شیفت / *Shift Type*). It writes through the real entities and
|
||||
drives the search projection through `ISearchIndexMaintainer.RebuildAsync` (never hand-inserts index rows),
|
||||
guarding each persona on its phone number so re-runs are a no-op. Invoked via `SeedDemoWorldAsync()`
|
||||
**only under `IsDevelopment()`** — never in Production/Staging. The demo world (phones, which nurse is
|
||||
|
||||
+22
@@ -1,5 +1,6 @@
|
||||
#nullable enable
|
||||
using Baya.Domain.Entities.Catalog;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Baya.Domain.Entities.Verification;
|
||||
using Baya.Infrastructure.Persistence.Configuration.GeographyConfig;
|
||||
|
||||
@@ -131,6 +132,19 @@ internal static class DemoWorldDefinitions
|
||||
]),
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Demo backoffice operators so the admin console is reachable through the same phone-OTP login the
|
||||
/// customers/nurses use (admin sub-roles are server-granted, never self-selectable via <c>select_role</c>,
|
||||
/// so they must be seeded). Two roles are seeded on purpose: a <c>super_admin</c> that sees every console
|
||||
/// (including RBAC) and a scoped <c>finance</c> operator, so the frontend's <c>useAdminCapabilities()</c>
|
||||
/// role-gating is demonstrable — the finance operator's sidebar shows only the money consoles.
|
||||
/// </summary>
|
||||
public static readonly AdminPersona[] Admins =
|
||||
[
|
||||
new AdminPersona("09120000020", "demo_admin_root", "نگار", "مدیری", "female", RoleNames.SuperAdmin),
|
||||
new AdminPersona("09120000021", "demo_admin_finance", "کامران", "مالی", "male", RoleNames.Finance),
|
||||
];
|
||||
|
||||
public static readonly CustomerPersona[] Customers =
|
||||
[
|
||||
new CustomerPersona(
|
||||
@@ -185,6 +199,14 @@ internal sealed record NursePersona(
|
||||
VariantDef[] Variants,
|
||||
AreaDef[] Areas);
|
||||
|
||||
internal sealed record AdminPersona(
|
||||
string Phone,
|
||||
string UserName,
|
||||
string Name,
|
||||
string FamilyName,
|
||||
string Gender,
|
||||
string RoleName);
|
||||
|
||||
internal sealed record CredentialDef(string Type, string IssuingAuthority, string Number);
|
||||
|
||||
internal sealed record BankDef(string BankName, string AccountHolderName, string Iban, bool MatchedNationalId);
|
||||
|
||||
+22
-3
@@ -48,19 +48,24 @@ internal sealed class DemoWorldSeeder(
|
||||
if (await EnsureCustomerAsync(persona, cancellationToken))
|
||||
seededCustomers++;
|
||||
|
||||
var seededAdmins = 0;
|
||||
foreach (var persona in DemoWorldDefinitions.Admins)
|
||||
if (await EnsureAdminAsync(persona, cancellationToken))
|
||||
seededAdmins++;
|
||||
|
||||
// Re-derive the whole search projection from source. Idempotent (drops + rebuilds), and the single
|
||||
// place is_searchable is computed — verified+accepting nurses with an active variant surface; the
|
||||
// unverified nurse does not.
|
||||
var rebuild = await searchIndex.RebuildAsync(cancellationToken);
|
||||
|
||||
if (seededNurses == 0 && seededCustomers == 0)
|
||||
if (seededNurses == 0 && seededCustomers == 0 && seededAdmins == 0)
|
||||
logger.LogInformation(
|
||||
"Demo world already seeded — no-op. Search index re-derived: {Nurses} nurses, {Rows} rows.",
|
||||
rebuild.NursesProcessed, rebuild.RowsWritten);
|
||||
else
|
||||
logger.LogInformation(
|
||||
"Demo world seeded: {Nurses} nurse(s), {Customers} customer(s). Search index: {IndexNurses} nurses, {Rows} searchable-eligible rows.",
|
||||
seededNurses, seededCustomers, rebuild.NursesProcessed, rebuild.RowsWritten);
|
||||
"Demo world seeded: {Nurses} nurse(s), {Customers} customer(s), {Admins} admin(s). Search index: {IndexNurses} nurses, {Rows} searchable-eligible rows.",
|
||||
seededNurses, seededCustomers, seededAdmins, rebuild.NursesProcessed, rebuild.RowsWritten);
|
||||
}
|
||||
|
||||
private async Task<(long GroupId, IReadOnlyDictionary<string, long> ValueIdByCode)> EnsureShiftTypeGroupAsync(
|
||||
@@ -262,6 +267,20 @@ internal sealed class DemoWorldSeeder(
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <returns><c>true</c> if the admin was newly created; <c>false</c> if it already existed.</returns>
|
||||
private async Task<bool> EnsureAdminAsync(AdminPersona persona, CancellationToken cancellationToken)
|
||||
{
|
||||
if (await userManager.GetUserByPhoneNumber(persona.Phone) is not null)
|
||||
return false;
|
||||
|
||||
// An admin is just a phone user + a server-granted admin role — no NurseProfile/CustomerProfile. This
|
||||
// is the only way to reach the /admin console through the phone-OTP login (admin sub-roles are never
|
||||
// self-selectable via me/select_role).
|
||||
await CreateUserAsync(persona.UserName, persona.Phone, persona.Name, persona.FamilyName,
|
||||
persona.Gender, nationalId: null, persona.RoleName, cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<User> CreateUserAsync(
|
||||
string userName, string phone, string name, string familyName, string gender,
|
||||
string? nationalId, string roleName, CancellationToken cancellationToken)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Net;
|
||||
using Baya.Application.Contracts.Identity;
|
||||
using Baya.Domain.Entities.Identity;
|
||||
using Baya.Domain.Entities.User;
|
||||
using Baya.Infrastructure.Persistence;
|
||||
using Baya.Infrastructure.Persistence.Services.Seeding;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -87,4 +89,25 @@ public class DemoWorldSeederTests(BayaApiFactory factory) : IClassFixture<BayaAp
|
||||
Assert.Equal(DemoWorldDefinitions.Nurses.Length, await db.Set<NurseProfile>().CountAsync());
|
||||
Assert.Equal(DemoWorldDefinitions.Customers.Length, await db.Set<CustomerProfile>().CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Seed_AdminPersonas_AreReachableWithTheirGrantedRoles()
|
||||
{
|
||||
await RunSeederAsync();
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var users = scope.ServiceProvider.GetRequiredService<IAppUserManager>();
|
||||
|
||||
// The super_admin can reach the /admin console (routes there) and sees the RBAC console.
|
||||
var root = await users.GetUserByPhoneNumber("09120000020");
|
||||
Assert.NotNull(root);
|
||||
Assert.Contains(RoleNames.SuperAdmin, await users.GetRoleAsync(root));
|
||||
|
||||
// The scoped finance operator carries only the finance role — the fine grain useAdminCapabilities gates on.
|
||||
var finance = await users.GetUserByPhoneNumber("09120000021");
|
||||
Assert.NotNull(finance);
|
||||
var financeRoles = await users.GetRoleAsync(finance);
|
||||
Assert.Contains(RoleNames.Finance, financeRoles);
|
||||
Assert.DoesNotContain(RoleNames.SuperAdmin, financeRoles);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user