refinement phase 2

This commit is contained in:
hamid
2026-07-13 01:14:35 +03:30
parent 0b45ec51f4
commit 1ce36f9414
22 changed files with 519 additions and 27 deletions
@@ -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);
@@ -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);
}
}