Files
ROLAC/API/ROLAC.API/Data/DbSeeder.cs
T
Chris Chen ad276c01f3 docs(1099): document Payee1099s/Form1099Boxes schema and seed Form1099 permissions
- DB_SCHEMA.md §8: add Form1099Box catalog table, Payee1099 recipient master
  (with TIN at-rest encryption note), and new FK columns on Expenses /
  ExpenseSubCategories / ExpenseCategoryGroups; update TOC and Seed Data section
- DbSeeder.cs: grant Modules.Form1099 to finance (R/W/D), pastor (R), and
  board_member (R), mirroring the Form990Report + Disbursements pattern;
  idempotent (only inserts if row absent, never clobbers admin edits)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:56:09 -07:00

535 lines
28 KiB
C#

using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Authorization;
using ROLAC.API.Entities;
namespace ROLAC.API.Data;
public static class DbSeeder
{
private static readonly (string En, string Zh, int Sort)[] GivingCategorySeed =
[
("Tithe", "什一奉獻", 1),
("General Offering", "一般奉獻", 2),
("Special Offering", "特別奉獻", 3),
("Building Fund", "建堂基金", 4),
("Mission", "宣教奉獻", 5),
];
private static readonly (string En, string Zh, int Sort)[] MinistrySeed =
[
("Administration", "行政", 1),
("Preaching", "講道", 2),
("Emcee", "司會", 3),
("Worship", "敬拜", 4),
("PPT/Media", "PPT/影音", 5),
("Sound", "音控", 6),
("Facility", "場地組", 7),
("Hospitality", "招待", 8),
("Children", "兒牧", 9),
("Catering", "餐飲", 10),
("Cell Groups", "小組牧養", 11),
("Special Events", "特別活動", 12),
];
// (GroupEn, GroupZh, Sort, SubItems[(SubEn, SubZh)])
private static readonly (string En, string Zh, int Sort, (string En, string Zh)[] Subs)[] ExpenseCategorySeed =
[
("Equipment", "設備", 1, [("Purchase","購置"),("Rental","租借"),("Maintenance & Repair","維修")]),
("Consumables", "消耗品", 2, [("Batteries","電池"),("Accessories","配件"),("Cleaning Supplies","清潔用品"),("Office Supplies","文具")]),
("Food & Beverage", "餐飲", 3, [("Catering","出餐費用"),("Food Ingredients","食材採購"),("Utensils","器具"),("Disposable Tableware","一次性餐具")]),
("Training", "培訓", 4, [("Course Fees","課程費用"),("Books","書籍"),("Conference","研討會"),("Travel","差旅")]),
("Materials", "教材", 5, [("Curriculum Printing","教材印刷"),("Craft Supplies","手工材料"),("Copyright & Licensing","版權購買")]),
("Facility", "場地", 6, [("Rent","場地租金"),("Utilities","水電"),("Property Insurance","財產保險"),("Decoration","裝飾"),("Repairs & Maintenance","修繕維護")]),
("Printing", "印刷", 7, [("Bulletins","週報"),("Order of Service","程序單"),("Posters","海報"),("Advertising & Promotion","廣告推廣")]),
("Missions", "宣教", 8, [("Offering Transfer","奉獻轉帳"),("Missionary Support","宣教士支援"),("Foreign Missions Support","國外宣教支援"),("Travel","差旅")]),
("Benevolence", "關懷救助", 9, [("Emergency Aid","急難救助"),("Condolence Gifts","慰問禮品"),("Visit Expenses","探訪費用")]),
("Other", "其他", 10, [("Miscellaneous","雜支"),("Gifts","禮品")]),
("Personnel", "人事", 11, [("Officer / Key Employee Compensation","主要職員薪酬"),("Salary & Wages","薪資"),("Payroll Taxes","薪資稅費"),("Employee Benefits","員工福利"),("Retirement / Pension","退休金"),("Workers Compensation","勞工保險"),("Honorarium","酬庸"),("Staff Training","同工進修"),("Contract Labor","外包勞務")]),
("Professional Services", "專業服務", 12, [("Legal","法律服務"),("Accounting & Audit","會計與審計"),("Other Professional","其他專業服務")]),
("Information Technology", "資訊科技", 13, [("Software & Subscriptions","軟體與訂閱"),("Website & Hosting","網站與主機"),("Internet & Telecom","網路與電信")]),
("Finance & Banking", "財務與銀行", 14, [("Interest","利息支出"),("Bank & Processing Fees","銀行/金流手續費")]),
];
// (LineCode, Name_en, Name_zh, Sort)
private static readonly (string Code, string En, string Zh, int Sort)[] Form990LineSeed =
[
("1", "Grants to domestic organizations", "對國內機構之捐贈", 1),
("2", "Grants to domestic individuals", "對國內個人之捐贈", 2),
("3", "Grants to foreign organizations/individuals", "對國外之捐贈", 3),
("5", "Compensation of current officers / key employees", "主要職員/負責人薪酬", 4),
("7", "Other salaries and wages", "薪資", 5),
("8", "Pension plan accruals and contributions", "退休金提撥", 6),
("9", "Other employee benefits", "員工福利", 7),
("10", "Payroll taxes", "薪資稅", 8),
("11b", "Legal fees", "法律服務費", 9),
("11c", "Accounting fees", "會計與審計費", 10),
("11g", "Other fees for services (non-employee)", "其他勞務報酬(非員工)", 11),
("12", "Advertising and promotion", "廣告與推廣", 12),
("13", "Office expenses", "辦公費用", 13),
("14", "Information technology", "資訊科技", 14),
("16", "Occupancy", "場地佔用", 15),
("17", "Travel", "差旅", 16),
("19", "Conferences, conventions, and meetings", "會議與研習", 17),
("20", "Interest", "利息", 18),
("22", "Depreciation", "折舊", 19),
("23", "Insurance", "保險", 20),
("24", "Other expenses", "其他費用", 21),
];
// (GroupEn, SubEn, LineCode) — default natural-category → 990 line mapping.
private static readonly (string GroupEn, string SubEn, string Code)[] Form990SubMappingSeed =
[
("Personnel", "Officer / Key Employee Compensation", "5"),
("Personnel", "Salary & Wages", "7"),
("Personnel", "Payroll Taxes", "10"),
("Personnel", "Employee Benefits", "9"),
("Personnel", "Retirement / Pension","8"),
("Personnel", "Workers Compensation","9"),
("Personnel", "Honorarium", "11g"),
("Personnel", "Contract Labor", "11g"),
("Personnel", "Staff Training", "19"),
("Facility", "Rent", "16"),
("Facility", "Utilities", "16"),
("Facility", "Property Insurance", "23"),
("Facility", "Decoration", "24"),
// Building repairs & maintenance (plumbing, electrical, painting) are part of Occupancy.
("Facility", "Repairs & Maintenance", "16"),
("Training", "Course Fees", "19"),
("Training", "Conference", "19"),
("Training", "Books", "24"),
("Training", "Travel", "17"),
("Missions", "Travel", "17"),
// Domestic missions support is paid to individual missionaries/families → line 2 (grants to individuals).
("Missions", "Offering Transfer", "2"),
("Missions", "Missionary Support", "2"),
("Missions", "Foreign Missions Support", "3"),
("Benevolence", "Emergency Aid", "2"),
("Benevolence", "Condolence Gifts", "2"),
// Visitation is the church's own travel/program cost, not a grant to an individual.
("Benevolence", "Visit Expenses", "17"),
("Consumables", "Office Supplies", "13"),
// General supplies belong with office expenses (line 13), not the "Other" catch-all.
("Consumables", "Batteries", "13"),
("Consumables", "Accessories", "13"),
("Consumables", "Cleaning Supplies", "13"),
// IRS line 13 covers equipment rental and maintenance.
("Equipment", "Rental", "13"),
("Equipment", "Maintenance & Repair", "13"),
("Printing", "Bulletins", "13"),
("Printing", "Order of Service", "13"),
("Printing", "Posters", "12"),
("Printing", "Advertising & Promotion", "12"),
("Materials", "Curriculum Printing", "13"),
// Classroom/craft supplies fall under IRS line 13 office expenses ("supplies… classroom…").
("Materials", "Craft Supplies", "13"),
("Professional Services", "Legal", "11b"),
("Professional Services", "Accounting & Audit", "11c"),
("Professional Services", "Other Professional", "11g"),
("Information Technology", "Software & Subscriptions", "14"),
("Information Technology", "Website & Hosting", "14"),
("Information Technology", "Internet & Telecom", "14"),
("Finance & Banking", "Interest", "20"),
// Bank/processing fees are office expenses per IRS line 13 (consistent with Interest → 20).
("Finance & Banking", "Bank & Processing Fees", "13"),
// Appreciation/outreach gifts have no natural 990 line; mapped to 24 explicitly so this
// deliberate "Other" choice doesn't inflate UnmappedExpenseCount. (Benevolence gifts → line 2.)
("Other", "Gifts", "24"),
];
private static readonly (string Code, string En, string Zh, string FormType, int Sort)[] Form1099BoxSeed =
[
(Form1099.BoxNec1, "Nonemployee compensation", "非員工報酬", "1099-NEC", 1),
(Form1099.BoxMisc1, "Rents", "租金", "1099-MISC", 2),
];
// Only service/rent subcategories get a box. Everything else stays unmapped (not reportable).
private static readonly (string GroupEn, string SubEn, string Code)[] Form1099SubMappingSeed =
[
("Personnel", "Honorarium", Form1099.BoxNec1),
("Personnel", "Contract Labor", Form1099.BoxNec1),
("Professional Services", "Legal", Form1099.BoxNec1),
("Professional Services", "Accounting & Audit", Form1099.BoxNec1),
("Professional Services", "Other Professional", Form1099.BoxNec1),
("Facility", "Rent", Form1099.BoxMisc1),
];
// One-time corrections for subcategories that were mapped to the WRONG line in an earlier
// seed. The normal mapping loop below only fills NULLs, so it cannot fix an existing bad
// value — this block does. Idempotent: each row fires only while the subcategory still holds
// the OLD line, so it never clobbers a deliberate admin re-mapping. (GroupEn, SubEn, Old, New)
private static readonly (string GroupEn, string SubEn, string OldCode, string NewCode)[] Form990RemapSeed =
[
("Benevolence", "Visit Expenses", "2", "17"),
("Missions", "Missionary Support", "1", "2"),
("Missions", "Offering Transfer", "1", "2"),
];
private static readonly (string Name, string Description)[] Roles =
[
("super_admin", "System administrator — full access"),
("pastor", "Pastor — full member and financial overview"),
("board_member", "Board member — church governance"),
("coworker_chair", "Coworker chair — coordinates ministry leaders"),
("ministry_leader", "Ministry leader — scoped to own ministry"),
("district_leader", "District leader — manages multiple cell groups"),
("cell_leader", "Cell leader — scoped to own cell group"),
("coworker", "Coworker — general worker in assigned ministry"),
("finance", "Finance — manages giving and expense reports"),
("secretary", "Secretary — manages member data and scheduling"),
("worship_leader", "Worship leader — manages song library and setlists (Phase deferred)"),
("member", "Member — views own profile and service roster"),
("visitor", "Visitor — public pages only"),
];
// Default permission matrix — mirrors the hard-coded [Authorize(Roles=...)] rules that
// existed before the configurable RBAC system, so day-one behavior is unchanged.
// super_admin is intentionally absent: it bypasses all checks (see PermissionAuthorizationHandler).
// R=Read, W=Write, D=Delete, A=Approve. Rows are inserted only if missing, so an admin's
// later edits via the Permissions UI are never clobbered on restart.
private static readonly (string Role, string Module, bool R, bool W, bool D, bool A)[] RolePermissionSeed =
[
// Secretary — manages member data.
("secretary", Modules.Members, true, true, true, false),
// Pastor — read-only overview of members and all expenses.
("pastor", Modules.Members, true, false, false, false),
("pastor", Modules.Expenses, true, false, false, false),
// Finance — full control over the finance modules.
("finance", Modules.Givings, true, true, true, false),
("finance", Modules.GivingCategories, true, true, true, false),
("finance", Modules.Expenses, true, true, true, true),
("finance", Modules.ExpenseCategories, true, true, true, false),
("finance", Modules.OfferingSessions, true, true, true, true),
("finance", Modules.FinanceDashboard, true, false, false, false),
("finance", Modules.MonthlyStatements, true, true, false, true),
("finance", Modules.ChurchProfile, true, true, false, false),
("finance", Modules.Disbursements, true, true, true, true),
("finance", Modules.Form990Report, true, false, false, false),
// Form1099 — finance manages recipients and tracks filings; pastor and board_member
// get read-only oversight (same pattern as Form990Report). No Approve semantics.
("finance", Modules.Form1099, true, true, true, false),
("pastor", Modules.Form1099, true, false, false, false),
("board_member", Modules.Form1099, true, false, false, false),
// Logs — read-only. System logs are technical (pastor only); audit logs have
// governance value, so finance and board members can read them too.
("pastor", Modules.SystemLogs, true, false, false, false),
("pastor", Modules.AuditLogs, true, false, false, false),
("finance", Modules.AuditLogs, true, false, false, false),
("board_member", Modules.AuditLogs, true, false, false, false),
("pastor", Modules.Form990Report, true, false, false, false),
("board_member", Modules.Form990Report, true, false, false, false),
// Ministries — secretary maintains the list; coworker_chair edits; ministry
// leaders and pastor read.
("secretary", Modules.Ministries, true, true, true, false),
("coworker_chair", Modules.Ministries, true, true, false, false),
("ministry_leader", Modules.Ministries, true, false, false, false),
("pastor", Modules.Ministries, true, false, false, false),
// Meal attendance — secretary and coworkers record; finance and pastor read.
("secretary", Modules.MealAttendance, true, true, false, false),
("coworker", Modules.MealAttendance, true, true, false, false),
("finance", Modules.MealAttendance, true, false, false, false),
("pastor", Modules.MealAttendance, true, false, false, false),
// Users, Permissions, and Settings are intentionally super_admin-only:
// super_admin bypasses all checks, so no seed rows are needed here.
];
public static async Task SeedRolePermissionsAsync(AppDbContext db)
{
var rolesByName = await db.Roles
.Where(r => r.Name != null)
.ToDictionaryAsync(r => r.Name!, r => r.Id);
foreach (var (role, module, read, write, delete, approve) in RolePermissionSeed)
{
if (!rolesByName.TryGetValue(role, out var roleId))
continue;
var exists = await db.RolePermissions.AnyAsync(p => p.RoleId == roleId && p.Module == module);
if (exists)
continue; // never clobber an admin's edit
db.RolePermissions.Add(new RolePermission
{
RoleId = roleId,
Module = module,
CanRead = read,
CanWrite = write,
CanDelete = delete,
CanApprove = approve,
});
}
await db.SaveChangesAsync();
}
public static async Task SeedRolesAsync(RoleManager<AppRole> roleManager)
{
foreach (var (name, description) in Roles)
{
if (!await roleManager.RoleExistsAsync(name))
{
await roleManager.CreateAsync(new AppRole
{
Name = name,
Description = description,
});
}
}
}
public static async Task SeedGivingCategoriesAsync(AppDbContext db)
{
foreach (var (en, zh, sort) in GivingCategorySeed)
{
if (!await db.GivingCategories.AnyAsync(c => c.Name_en == en))
{
db.GivingCategories.Add(new GivingCategory
{
Name_en = en,
Name_zh = zh,
SortOrder = sort,
IsActive = true,
// Audit fields are stamped by AuditSaveChangesInterceptor on save.
});
}
}
await db.SaveChangesAsync();
}
public static async Task SeedMinistriesAsync(AppDbContext db)
{
foreach (var (en, zh, sort) in MinistrySeed)
{
if (!await db.Ministries.AnyAsync(m => m.Name_en == en))
db.Ministries.Add(new Ministry
{
Name_en = en, Name_zh = zh, SortOrder = sort, IsActive = true,
DefaultFunctionalClass = en == "Administration"
? FunctionalClasses.ManagementGeneral
: FunctionalClasses.Program,
});
}
await db.SaveChangesAsync();
}
public static async Task SeedExpenseCategoriesAsync(AppDbContext db)
{
// One-time renames to remove same-name-different-parent ambiguity. Idempotent:
// only fires while the old name still exists. (New installs never hit this.)
var renames = new (string GroupEn, string OldSub, string NewEn, string NewZh)[]
{
("Food & Beverage", "Consumables", "Disposable Tableware", "一次性餐具"),
("Materials", "Printing", "Curriculum Printing", "教材印刷"),
};
foreach (var (groupEn, oldSub, newEn, newZh) in renames)
{
var grp = await db.ExpenseCategoryGroups.FirstOrDefaultAsync(g => g.Name_en == groupEn);
if (grp is null) continue;
var sub = await db.ExpenseSubCategories.FirstOrDefaultAsync(s => s.GroupId == grp.Id && s.Name_en == oldSub);
if (sub is not null) { sub.Name_en = newEn; sub.Name_zh = newZh; }
}
await db.SaveChangesAsync();
foreach (var (gEn, gZh, gSort, subs) in ExpenseCategorySeed)
{
var group = await db.ExpenseCategoryGroups.FirstOrDefaultAsync(g => g.Name_en == gEn);
if (group is null)
{
group = new ExpenseCategoryGroup { Name_en = gEn, Name_zh = gZh, SortOrder = gSort, IsActive = true };
db.ExpenseCategoryGroups.Add(group);
await db.SaveChangesAsync(); // assign group.Id
}
var sub = 1;
foreach (var (sEn, sZh) in subs)
{
if (!await db.ExpenseSubCategories.AnyAsync(s => s.GroupId == group.Id && s.Name_en == sEn))
db.ExpenseSubCategories.Add(new ExpenseSubCategory
{ GroupId = group.Id, Name_en = sEn, Name_zh = sZh, SortOrder = sub, IsActive = true });
sub++;
}
}
await db.SaveChangesAsync();
}
public static async Task SeedForm990ExpenseLinesAsync(AppDbContext db)
{
foreach (var (code, en, zh, sort) in Form990LineSeed)
{
if (!await db.Form990ExpenseLines.AnyAsync(l => l.LineCode == code))
db.Form990ExpenseLines.Add(new Form990ExpenseLine
{ LineCode = code, Name_en = en, Name_zh = zh, SortOrder = sort, IsActive = true });
}
await db.SaveChangesAsync();
var linesByCode = await db.Form990ExpenseLines.ToDictionaryAsync(l => l.LineCode, l => l.Id);
var fallbackId = linesByCode["24"];
// Every group defaults to line 24 (safety net); precise mapping lives on subcategories.
foreach (var group in await db.ExpenseCategoryGroups.ToListAsync())
group.Form990LineId ??= fallbackId;
// Subcategory default mappings — only set when not already mapped (never clobber an admin edit).
var subsByKey = await db.ExpenseSubCategories.Include(s => s.Group).ToListAsync();
foreach (var (groupEn, subEn, code) in Form990SubMappingSeed)
{
var sub = subsByKey.FirstOrDefault(s => s.Group!.Name_en == groupEn && s.Name_en == subEn);
if (sub is not null && sub.Form990LineId is null && linesByCode.TryGetValue(code, out var lineId))
sub.Form990LineId = lineId;
}
// Correct earlier mis-mappings on existing databases (see Form990RemapSeed). Only fires
// while the subcategory still holds the OLD line, so a later admin edit is never clobbered.
foreach (var (groupEn, subEn, oldCode, newCode) in Form990RemapSeed)
{
var sub = subsByKey.FirstOrDefault(s => s.Group!.Name_en == groupEn && s.Name_en == subEn);
if (sub is null) continue;
if (linesByCode.TryGetValue(oldCode, out var oldId)
&& linesByCode.TryGetValue(newCode, out var newId)
&& sub.Form990LineId == oldId)
sub.Form990LineId = newId;
}
await db.SaveChangesAsync();
}
public static async Task SeedForm1099BoxesAsync(AppDbContext db)
{
foreach (var (code, en, zh, formType, sort) in Form1099BoxSeed)
if (!await db.Form1099Boxes.AnyAsync(b => b.BoxCode == code))
db.Form1099Boxes.Add(new Form1099Box
{ BoxCode = code, Name_en = en, Name_zh = zh, FormType = formType, SortOrder = sort, IsActive = true });
await db.SaveChangesAsync();
var boxesByCode = await db.Form1099Boxes.ToDictionaryAsync(b => b.BoxCode, b => b.Id);
var subs = await db.ExpenseSubCategories.Include(s => s.Group).ToListAsync();
foreach (var (groupEn, subEn, code) in Form1099SubMappingSeed)
{
var sub = subs.FirstOrDefault(s => s.Group!.Name_en == groupEn && s.Name_en == subEn);
if (sub is not null && sub.Form1099BoxId is null && boxesByCode.TryGetValue(code, out var boxId))
sub.Form1099BoxId = boxId;
}
await db.SaveChangesAsync();
}
public static async Task SeedChurchProfileAsync(AppDbContext db)
{
// Singleton row used by the disbursement module (issuer info + check counter).
if (!await db.ChurchProfiles.AnyAsync())
{
db.ChurchProfiles.Add(new ChurchProfile
{
Name = "River Of Life Christian Church",
City = "Arcadia",
State = "CA",
NextCheckNumber = 1001,
});
await db.SaveChangesAsync();
}
}
public static async Task SeedSiteSettingAsync(AppDbContext db)
{
// Singleton row holding site-wide presentation/locale settings.
if (!await db.SiteSettings.AnyAsync())
{
db.SiteSettings.Add(new SiteSetting
{
SiteTitle = "River Of Life Christian Church",
SiteTitleZh = "生命河靈糧堂",
DefaultLanguage = "en",
TimeZone = "America/Los_Angeles",
DateFormat = "yyyy-MM-dd",
Currency = "USD",
});
await db.SaveChangesAsync();
}
}
public static async Task SeedNotificationSettingAsync(AppDbContext db, IConfiguration config)
{
// Singleton row that becomes the runtime source of truth for SMTP + Line. Seed it once
// from the legacy "Smtp"/"Line" appsettings sections so existing config carries over.
if (!await db.NotificationSettings.AnyAsync())
{
var smtp = config.GetSection("Smtp");
var line = config.GetSection("Line");
db.NotificationSettings.Add(new NotificationSetting
{
EnableEmail = !string.IsNullOrWhiteSpace(smtp["Host"]),
SmtpHost = smtp["Host"] ?? "",
SmtpPort = int.TryParse(smtp["Port"], out var port) ? port : 587,
SmtpUseSsl = !bool.TryParse(smtp["UseSsl"], out var ssl) || ssl,
SmtpUser = smtp["User"] ?? "",
SmtpPassword = smtp["Password"] ?? "",
FromAddress = smtp["FromAddress"] ?? "",
FromName = smtp["FromName"] ?? "",
EnableLine = !string.IsNullOrWhiteSpace(line["ChannelAccessToken"]),
LineChannelAccessToken = line["ChannelAccessToken"] ?? "",
LineChannelSecret = line["ChannelSecret"] ?? "",
});
await db.SaveChangesAsync();
}
}
/// <summary>
/// Seeds roles and (in Development) the default admin account.
/// Called once on application startup after migrations have been applied.
/// </summary>
public static async Task SeedAsync(IServiceProvider services)
{
var roleManager = services.GetRequiredService<RoleManager<AppRole>>();
var userManager = services.GetRequiredService<UserManager<AppUser>>();
var env = services.GetRequiredService<IWebHostEnvironment>();
var config = services.GetRequiredService<IConfiguration>();
await SeedRolesAsync(roleManager);
var db = services.GetRequiredService<AppDbContext>();
await SeedRolePermissionsAsync(db);
await SeedGivingCategoriesAsync(db);
await SeedMinistriesAsync(db);
await SeedExpenseCategoriesAsync(db);
await SeedForm990ExpenseLinesAsync(db);
await SeedForm1099BoxesAsync(db);
await SeedChurchProfileAsync(db);
await SeedSiteSettingAsync(db);
await SeedNotificationSettingAsync(db, config);
if (env.IsDevelopment())
await SeedAdminUserAsync(userManager);
}
/// <summary>
/// Creates a super_admin test account for local development.
/// DO NOT call this in production — remove or guard with IsDevelopment().
/// Credentials: admin@rolac.org / Admin1234!
/// </summary>
public static async Task SeedAdminUserAsync(UserManager<AppUser> userManager)
{
const string adminEmail = "admin@rolac.org";
const string adminPassword = "Admin1234!";
if (await userManager.FindByEmailAsync(adminEmail) is null)
{
var admin = new AppUser
{
UserName = adminEmail,
Email = adminEmail,
EmailConfirmed = true,
IsActive = true,
LanguagePreference = "en",
CreatedAt = DateTime.UtcNow,
};
var result = await userManager.CreateAsync(admin, adminPassword);
if (result.Succeeded)
await userManager.AddToRoleAsync(admin, "super_admin");
}
}
}