This commit is contained in:
Thanakarn Klangkasame
2025-09-30 11:01:02 +07:00
commit 92e614674c
182 changed files with 9596 additions and 0 deletions

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\AMREZ.EOP.Abstractions\AMREZ.EOP.Abstractions.csproj" />
<ProjectReference Include="..\AMREZ.EOP.Application\AMREZ.EOP.Application.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.9" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.9" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="StackExchange.Redis" Version="2.9.17" />
</ItemGroup>
<ItemGroup>
<Folder Include="Migrations\" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,365 @@
using AMREZ.EOP.Domain.Entities.Authentications;
using AMREZ.EOP.Domain.Entities.Common;
using AMREZ.EOP.Domain.Entities.HumanResources;
using AMREZ.EOP.Domain.Entities.Tenancy;
using Microsoft.EntityFrameworkCore;
namespace AMREZ.EOP.Infrastructures.Data;
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
// ===== Auth =====
public DbSet<User> Users => Set<User>();
public DbSet<UserIdentity> UserIdentities => Set<UserIdentity>();
public DbSet<UserMfaFactor> UserMfaFactors => Set<UserMfaFactor>();
public DbSet<UserSession> UserSessions => Set<UserSession>();
public DbSet<UserPasswordHistory> UserPasswordHistories => Set<UserPasswordHistory>();
public DbSet<UserExternalAccount> UserExternalAccounts => Set<UserExternalAccount>();
public DbSet<Role> Roles => Set<Role>();
public DbSet<Permission> Permissions => Set<Permission>();
public DbSet<UserRole> UserRoles => Set<UserRole>();
public DbSet<RolePermission> RolePermissions => Set<RolePermission>();
// ===== HR =====
public DbSet<UserProfile> UserProfiles => Set<UserProfile>();
public DbSet<Department> Departments => Set<Department>();
public DbSet<Position> Positions => Set<Position>();
public DbSet<Employment> Employments => Set<Employment>();
public DbSet<EmployeeAddress> EmployeeAddresses => Set<EmployeeAddress>();
public DbSet<EmergencyContact> EmergencyContacts => Set<EmergencyContact>();
public DbSet<EmployeeBankAccount> EmployeeBankAccounts => Set<EmployeeBankAccount>();
// ===== Tenancy (meta) =====
public DbSet<TenantConfig> Tenants => Set<TenantConfig>();
public DbSet<TenantDomain> TenantDomains => Set<TenantDomain>();
protected override void OnModelCreating(ModelBuilder model)
{
// ====== Global Tenancy Config (meta schema) — ไม่สืบทอด BaseEntity ======
model.Entity<TenantConfig>(b =>
{
b.ToTable("tenants", schema: "meta");
b.HasKey(x => x.TenantKey);
b.Property(x => x.TenantKey).HasMaxLength(128).IsRequired();
b.Property(x => x.Schema).HasMaxLength(128);
b.Property(x => x.ConnectionString);
b.Property(x => x.Mode).IsRequired();
b.Property(x => x.IsActive).HasDefaultValue(true);
b.Property(x => x.UpdatedAtUtc)
.HasColumnName("updated_at_utc")
.HasDefaultValueSql("now() at time zone 'utc'");
b.HasIndex(x => x.IsActive);
});
model.Entity<TenantDomain>(b =>
{
b.ToTable("tenant_domains", schema: "meta");
b.HasKey(x => x.Domain);
b.Property(x => x.Domain).HasMaxLength(253).IsRequired();
b.Property(x => x.TenantKey).HasMaxLength(128);
b.Property(x => x.IsPlatformBaseDomain).HasDefaultValue(false);
b.Property(x => x.IsActive).HasDefaultValue(true);
b.Property(x => x.UpdatedAtUtc)
.HasColumnName("updated_at_utc")
.HasDefaultValueSql("now() at time zone 'utc'");
b.HasIndex(x => x.TenantKey);
b.HasIndex(x => x.IsPlatformBaseDomain);
b.HasIndex(x => x.IsActive);
b.HasOne<TenantConfig>()
.WithMany()
.HasForeignKey(x => x.TenantKey)
.OnDelete(DeleteBehavior.Cascade);
});
// ====== Auth ======
model.Entity<User>(b =>
{
b.ToTable("users");
b.HasKey(x => x.Id);
b.Property(x => x.PasswordHash).IsRequired();
b.Property(x => x.IsActive).HasDefaultValue(true);
b.Property(x => x.AccessFailedCount).HasDefaultValue(0);
b.Property(x => x.MfaEnabled).HasDefaultValue(false);
b.HasMany(x => x.Identities)
.WithOne(i => i.User)
.HasForeignKey(i => i.UserId)
.OnDelete(DeleteBehavior.Cascade);
b.HasMany(x => x.MfaFactors)
.WithOne(i => i.User)
.HasForeignKey(i => i.UserId)
.OnDelete(DeleteBehavior.Cascade);
b.HasMany(x => x.Sessions)
.WithOne(s => s.User)
.HasForeignKey(s => s.UserId)
.OnDelete(DeleteBehavior.Cascade);
b.HasMany(x => x.PasswordHistories)
.WithOne(ph => ph.User)
.HasForeignKey(ph => ph.UserId)
.OnDelete(DeleteBehavior.Cascade);
b.HasMany(x => x.ExternalAccounts)
.WithOne(ea => ea.User)
.HasForeignKey(ea => ea.UserId)
.OnDelete(DeleteBehavior.Cascade);
});
model.Entity<UserIdentity>(b =>
{
b.ToTable("user_identities");
b.HasKey(x => x.Id);
b.Property(x => x.Type).IsRequired();
b.Property(x => x.Identifier).IsRequired().HasMaxLength(256);
b.Property(x => x.IsPrimary).HasDefaultValue(false);
b.HasIndex(x => new { x.TenantId, x.Type, x.Identifier })
.IsUnique();
b.HasIndex(x => new { x.TenantId, x.UserId, x.Type, x.IsPrimary })
.HasDatabaseName("ix_user_identity_primary_per_type");
});
model.Entity<UserMfaFactor>(b =>
{
b.ToTable("user_mfa_factors");
b.HasKey(x => x.Id);
b.Property(x => x.Type).IsRequired();
b.Property(x => x.Enabled).HasDefaultValue(true);
b.HasIndex(x => new { x.TenantId, x.UserId });
});
model.Entity<UserSession>(b =>
{
b.ToTable("user_sessions");
b.HasKey(x => x.Id);
b.Property(x => x.RefreshTokenHash).IsRequired();
b.HasIndex(x => new { x.TenantId, x.UserId });
b.HasIndex(x => new { x.TenantId, x.DeviceId });
});
model.Entity<UserPasswordHistory>(b =>
{
b.ToTable("user_password_histories");
b.HasKey(x => x.Id);
b.Property(x => x.PasswordHash).IsRequired();
b.HasIndex(x => new { x.TenantId, x.UserId, x.ChangedAt });
});
model.Entity<UserExternalAccount>(b =>
{
b.ToTable("user_external_accounts");
b.HasKey(x => x.Id);
b.Property(x => x.Provider).IsRequired();
b.Property(x => x.Subject).IsRequired();
b.HasIndex(x => new { x.TenantId, x.Provider, x.Subject })
.IsUnique();
});
model.Entity<Role>(b =>
{
b.ToTable("roles");
b.HasKey(x => x.Id);
b.Property(x => x.Code).IsRequired().HasMaxLength(128);
b.Property(x => x.Name).IsRequired().HasMaxLength(256);
b.HasIndex(x => new { x.TenantId, x.Code }).IsUnique();
});
model.Entity<Permission>(b =>
{
b.ToTable("permissions");
b.HasKey(x => x.Id);
b.Property(x => x.Code).IsRequired().HasMaxLength(256);
b.Property(x => x.Name).IsRequired().HasMaxLength(256);
b.HasIndex(x => new { x.TenantId, x.Code }).IsUnique();
});
model.Entity<UserRole>(b =>
{
b.ToTable("user_roles");
b.HasKey(x => x.Id);
b.HasIndex(x => new { x.TenantId, x.UserId, x.RoleId }).IsUnique();
});
model.Entity<RolePermission>(b =>
{
b.ToTable("role_permissions");
b.HasKey(x => x.Id);
b.HasIndex(x => new { x.TenantId, x.RoleId, x.PermissionId }).IsUnique();
});
// ====== HR ======
model.Entity<UserProfile>(b =>
{
b.ToTable("user_profiles");
b.HasKey(x => x.Id);
b.Property(x => x.FirstName).IsRequired().HasMaxLength(128);
b.Property(x => x.LastName).IsRequired().HasMaxLength(128);
b.HasOne(x => x.User)
.WithOne()
.HasForeignKey<UserProfile>(x => x.UserId)
.OnDelete(DeleteBehavior.Cascade);
b.HasIndex(x => new { x.TenantId, x.UserId }).IsUnique();
});
model.Entity<Department>(b =>
{
b.ToTable("departments");
b.HasKey(x => x.Id);
b.Property(x => x.Code).IsRequired().HasMaxLength(64);
b.Property(x => x.Name).IsRequired().HasMaxLength(256);
b.HasIndex(x => new { x.TenantId, x.Code }).IsUnique();
b.HasOne(x => x.Parent)
.WithMany(x => x.Children)
.HasForeignKey(x => x.ParentDepartmentId)
.OnDelete(DeleteBehavior.Restrict);
});
model.Entity<Position>(b =>
{
b.ToTable("positions");
b.HasKey(x => x.Id);
b.Property(x => x.Code).IsRequired().HasMaxLength(64);
b.Property(x => x.Title).IsRequired().HasMaxLength(256);
b.HasIndex(x => new { x.TenantId, x.Code }).IsUnique();
});
model.Entity<Employment>(b =>
{
b.ToTable("employments");
b.HasKey(x => x.Id);
b.Property(x => x.EmploymentType).IsRequired();
b.Property(x => x.StartDate).IsRequired();
b.HasIndex(x => new { x.TenantId, x.UserProfileId, x.StartDate });
b.HasOne(x => x.UserProfile)
.WithMany(p => p.Employments)
.HasForeignKey(x => x.UserProfileId)
.OnDelete(DeleteBehavior.Cascade);
b.HasOne(x => x.Department)
.WithMany()
.HasForeignKey(x => x.DepartmentId)
.OnDelete(DeleteBehavior.Restrict);
b.HasOne(x => x.Position)
.WithMany()
.HasForeignKey(x => x.PositionId)
.OnDelete(DeleteBehavior.Restrict);
});
model.Entity<EmployeeAddress>(b =>
{
b.ToTable("employee_addresses");
b.HasKey(x => x.Id);
b.Property(x => x.Line1).IsRequired().HasMaxLength(256);
b.Property(x => x.City).IsRequired().HasMaxLength(128);
b.Property(x => x.PostalCode).IsRequired().HasMaxLength(32);
b.Property(x => x.Country).IsRequired().HasMaxLength(64);
b.HasOne(x => x.UserProfile)
.WithMany(p => p.Addresses)
.HasForeignKey(x => x.UserProfileId)
.OnDelete(DeleteBehavior.Cascade);
b.HasIndex(x => new { x.TenantId, x.UserProfileId, x.IsPrimary });
});
model.Entity<EmergencyContact>(b =>
{
b.ToTable("emergency_contacts");
b.HasKey(x => x.Id);
b.Property(x => x.Name).IsRequired().HasMaxLength(128);
b.Property(x => x.Relationship).IsRequired().HasMaxLength(64);
b.HasOne(x => x.UserProfile)
.WithMany(p => p.EmergencyContacts)
.HasForeignKey(x => x.UserProfileId)
.OnDelete(DeleteBehavior.Cascade);
b.HasIndex(x => new { x.TenantId, x.UserProfileId, x.IsPrimary });
});
model.Entity<EmployeeBankAccount>(b =>
{
b.ToTable("employee_bank_accounts");
b.HasKey(x => x.Id);
b.Property(x => x.BankName).IsRequired().HasMaxLength(128);
b.Property(x => x.AccountNumber).IsRequired().HasMaxLength(64);
b.Property(x => x.AccountHolder).IsRequired().HasMaxLength(128);
b.HasOne(x => x.UserProfile)
.WithMany(p => p.BankAccounts)
.HasForeignKey(x => x.UserProfileId)
.OnDelete(DeleteBehavior.Cascade);
b.HasIndex(x => new { x.TenantId, x.UserProfileId, x.IsPrimary });
});
// ====== Enums as ints ======
model.Entity<UserIdentity>().Property(x => x.Type).HasConversion<int>();
model.Entity<UserMfaFactor>().Property(x => x.Type).HasConversion<int>();
model.Entity<UserExternalAccount>().Property(x => x.Provider).HasConversion<int>();
model.Entity<Employment>().Property(x => x.EmploymentType).HasConversion<int>();
model.Entity<UserProfile>().Property(x => x.Gender).HasConversion<int?>();
// ====== BaseEntity common mapping ======
foreach (var et in model.Model.GetEntityTypes()
.Where(t => typeof(BaseEntity).IsAssignableFrom(t.ClrType)))
{
var b = model.Entity(et.ClrType);
// Tenant
b.Property<Guid>(nameof(BaseEntity.TenantId))
.HasColumnName("tenant_id")
.HasColumnType("uuid")
.IsRequired()
.HasDefaultValueSql("nullif(current_setting('app.tenant_id', true),'')::uuid");
b.HasIndex(nameof(BaseEntity.TenantId));
b.HasCheckConstraint($"ck_{et.GetTableName()}_tenant_not_null", "tenant_id is not null");
// Audit
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnName("created_at")
.HasDefaultValueSql("now() at time zone 'utc'");
b.Property<DateTimeOffset?>("UpdatedAt").HasColumnName("updated_at");
b.Property<string?>("CreatedBy").HasColumnName("created_by");
b.Property<string?>("UpdatedBy").HasColumnName("updated_by");
b.Property<bool>("IsDeleted").HasColumnName("is_deleted").HasDefaultValue(false);
}
}
}

View File

@@ -0,0 +1,137 @@
using AMREZ.EOP.Abstractions.Applications.Tenancy;
using AMREZ.EOP.Abstractions.Applications.UseCases.Authentications;
using AMREZ.EOP.Abstractions.Applications.UseCases.HumanResources;
using AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy;
using AMREZ.EOP.Abstractions.Infrastructures.Common;
using AMREZ.EOP.Abstractions.Infrastructures.Repositories;
using AMREZ.EOP.Abstractions.Security;
using AMREZ.EOP.Abstractions.Storage;
using AMREZ.EOP.Application.UseCases.Authentications;
using AMREZ.EOP.Application.UseCases.HumanResources;
using AMREZ.EOP.Application.UseCases.Tenancy;
using AMREZ.EOP.Domain.Shared.Tenancy;
using AMREZ.EOP.Infrastructures.Data;
using AMREZ.EOP.Infrastructures.Options;
using AMREZ.EOP.Infrastructures.Repositories;
using AMREZ.EOP.Infrastructures.Security;
using AMREZ.EOP.Infrastructures.Storage;
using AMREZ.EOP.Infrastructures.Tenancy;
using AMREZ.EOP.Infrastructures.UnitOfWork;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using StackExchange.Redis;
namespace AMREZ.EOP.Infrastructures.DependencyInjections;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddInfrastructure(this IServiceCollection services)
{
services.AddHttpContextAccessor();
// Options
services.AddOptions<AuthOptions>()
.BindConfiguration("Connections")
.ValidateOnStart();
// Tenancy core (order matters for AddDbContext factory resolution)
services.AddSingleton(sp =>
{
var map = new TenantMap();
var opts = sp.GetRequiredService<IOptions<AuthOptions>>().Value;
map.UpsertTenant("public", new TenantContext
{
Id = "public",
Schema = "public",
ConnectionString = string.IsNullOrWhiteSpace(opts.DefaultConnection) ? null : opts.DefaultConnection,
Mode = TenantMode.Rls
});
return map;
});
services.AddHostedService<TenantMapLoader>();
services.AddScoped<SearchPathInterceptor>();
services.AddScoped<TenantRlsInterceptor>();
services.AddScoped<ITenantResolver, DefaultTenantResolver>();
services.AddScoped<ITenantDbContextFactory, TenantDbContextFactory>();
services.AddScoped<ITenantProvisioner, TenantProvisioner>();
// DbContext (attach RLS/search_path interceptors)
services.AddDbContext<AppDbContext>((sp, o) =>
{
var cfg = sp.GetRequiredService<IOptions<AuthOptions>>().Value;
o.UseNpgsql(cfg.DefaultConnection);
o.AddInterceptors(
sp.GetRequiredService<SearchPathInterceptor>(),
sp.GetRequiredService<TenantRlsInterceptor>()
);
});
// Db scope / UoW
services.AddScoped<IDbScope, DbScope>();
services.AddScoped<EFUnitOfWork>();
services.AddScoped<IUnitOfWork, EFUnitOfWork>();
// Security
services.AddScoped<IPasswordHasher, BcryptPasswordHasher>();
// Repositories — Auth & HR แยกกัน
services.AddScoped<IUserRepository, UserRepository>();
services.AddScoped<IUserProfileRepository, UserProfileRepository>();
services.AddScoped<ITenantRepository, TenantRepository>();
// UseCases — Authentication
services.AddScoped<ILoginUseCase, LoginUseCase>();
services.AddScoped<IRegisterUseCase, RegisterUseCase>();
services.AddScoped<IChangePasswordUseCase, ChangePasswordUseCase>();
services.AddScoped<IAddEmailIdentityUseCase, AddEmailIdentityUseCase>();
services.AddScoped<IVerifyEmailUseCase, VerifyEmailUseCase>();
services.AddScoped<IEnableTotpUseCase, EnableTotpUseCase>();
services.AddScoped<IDisableMfaUseCase, DisableMfaUseCase>();
services.AddScoped<ILogoutUseCase, LogoutUseCase>();
services.AddScoped<ILogoutAllUseCase, LogoutAllUseCase>();
// UseCases — HR
services.AddScoped<IUpsertUserProfileUseCase, UpsertUserProfileUseCase>();
services.AddScoped<IAddEmploymentUseCase, AddEmploymentUseCase>();
services.AddScoped<IEndEmploymentUseCase, EndEmploymentUseCase>();
services.AddScoped<IAddEmployeeAddressUseCase, AddEmployeeAddressUseCase>();
services.AddScoped<ISetPrimaryAddressUseCase, SetPrimaryAddressUseCase>();
services.AddScoped<IAddEmergencyContactUseCase, AddEmergencyContactUseCase>();
services.AddScoped<ISetPrimaryEmergencyContactUseCase, SetPrimaryEmergencyContactUseCase>();
services.AddScoped<IAddEmployeeBankAccountUseCase, AddEmployeeBankAccountUseCase>();
services.AddScoped<ISetPrimaryBankAccountUseCase, SetPrimaryBankAccountUseCase>();
// UseCases — Tenancy
services.AddScoped<ICreateTenantUseCase, CreateTenantUseCase>();
services.AddScoped<IUpdateTenantUseCase, UpdateTenantUseCase>();
services.AddScoped<IDeleteTenantUseCase, DeleteTenantUseCase>();
services.AddScoped<IMapDomainUseCase, MapDomainUseCase>();
services.AddScoped<IUnmapDomainUseCase, UnmapDomainUseCase>();
services.AddScoped<IAddBaseDomainUseCase, AddBaseDomainUseCase>();
services.AddScoped<IRemoveBaseDomainUseCase, RemoveBaseDomainUseCase>();
services.AddScoped<IListTenantsUseCase, ListTenantsUseCase>();
services.AddScoped<IListDomainsUseCase, ListDomainsUseCase>();
// Redis (optional)
services.AddSingleton<IConnectionMultiplexer?>(sp =>
{
var opts = sp.GetRequiredService<IOptions<AuthOptions>>().Value;
var conn = opts.RedisConnection;
if (string.IsNullOrWhiteSpace(conn)) return null;
try
{
var cfg = ConfigurationOptions.Parse(conn, true);
cfg.AbortOnConnectFail = false;
cfg.ConnectTimeout = 1000;
cfg.SyncTimeout = 1000;
var mux = ConnectionMultiplexer.Connect(cfg);
return mux.IsConnected ? mux : null;
}
catch { return null; }
});
return services;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,935 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace AMREZ.EOP.Infrastructures.Migrations
{
/// <inheritdoc />
public partial class InitDatabase : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "meta");
migrationBuilder.CreateTable(
name: "departments",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "nullif(current_setting('app.tenant_id', true),'')::uuid"),
Code = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
ParentDepartmentId = table.Column<Guid>(type: "uuid", nullable: true),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"),
created_by = table.Column<string>(type: "text", nullable: true),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
updated_by = table.Column<string>(type: "text", nullable: true),
is_deleted = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false)
},
constraints: table =>
{
table.PrimaryKey("PK_departments", x => x.Id);
table.CheckConstraint("ck_departments_tenant_not_null", "tenant_id is not null");
table.ForeignKey(
name: "FK_departments_departments_ParentDepartmentId",
column: x => x.ParentDepartmentId,
principalTable: "departments",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "permissions",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "nullif(current_setting('app.tenant_id', true),'')::uuid"),
Code = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"),
created_by = table.Column<string>(type: "text", nullable: true),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
updated_by = table.Column<string>(type: "text", nullable: true),
is_deleted = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false)
},
constraints: table =>
{
table.PrimaryKey("PK_permissions", x => x.Id);
table.CheckConstraint("ck_permissions_tenant_not_null", "tenant_id is not null");
});
migrationBuilder.CreateTable(
name: "positions",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "nullif(current_setting('app.tenant_id', true),'')::uuid"),
Code = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
Title = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
Level = table.Column<int>(type: "integer", nullable: true),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"),
created_by = table.Column<string>(type: "text", nullable: true),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
updated_by = table.Column<string>(type: "text", nullable: true),
is_deleted = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false)
},
constraints: table =>
{
table.PrimaryKey("PK_positions", x => x.Id);
table.CheckConstraint("ck_positions_tenant_not_null", "tenant_id is not null");
});
migrationBuilder.CreateTable(
name: "roles",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "nullif(current_setting('app.tenant_id', true),'')::uuid"),
Code = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"),
created_by = table.Column<string>(type: "text", nullable: true),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
updated_by = table.Column<string>(type: "text", nullable: true),
is_deleted = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false)
},
constraints: table =>
{
table.PrimaryKey("PK_roles", x => x.Id);
table.CheckConstraint("ck_roles_tenant_not_null", "tenant_id is not null");
});
migrationBuilder.CreateTable(
name: "tenants",
schema: "meta",
columns: table => new
{
TenantKey = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
Schema = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
ConnectionString = table.Column<string>(type: "text", nullable: true),
Mode = table.Column<int>(type: "integer", nullable: false),
IsActive = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
updated_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'")
},
constraints: table =>
{
table.PrimaryKey("PK_tenants", x => x.TenantKey);
});
migrationBuilder.CreateTable(
name: "users",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "nullif(current_setting('app.tenant_id', true),'')::uuid"),
PasswordHash = table.Column<string>(type: "text", nullable: false),
IsActive = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
AccessFailedCount = table.Column<int>(type: "integer", nullable: false, defaultValue: 0),
LockoutEndUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
MfaEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
SecurityStamp = table.Column<string>(type: "text", nullable: true),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"),
created_by = table.Column<string>(type: "text", nullable: true),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
updated_by = table.Column<string>(type: "text", nullable: true),
is_deleted = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false)
},
constraints: table =>
{
table.PrimaryKey("PK_users", x => x.Id);
table.CheckConstraint("ck_users_tenant_not_null", "tenant_id is not null");
});
migrationBuilder.CreateTable(
name: "role_permissions",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "nullif(current_setting('app.tenant_id', true),'')::uuid"),
RoleId = table.Column<Guid>(type: "uuid", nullable: false),
PermissionId = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"),
created_by = table.Column<string>(type: "text", nullable: true),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
updated_by = table.Column<string>(type: "text", nullable: true),
is_deleted = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false)
},
constraints: table =>
{
table.PrimaryKey("PK_role_permissions", x => x.Id);
table.CheckConstraint("ck_role_permissions_tenant_not_null", "tenant_id is not null");
table.ForeignKey(
name: "FK_role_permissions_permissions_PermissionId",
column: x => x.PermissionId,
principalTable: "permissions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_role_permissions_roles_RoleId",
column: x => x.RoleId,
principalTable: "roles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "tenant_domains",
schema: "meta",
columns: table => new
{
Domain = table.Column<string>(type: "character varying(253)", maxLength: 253, nullable: false),
TenantKey = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
IsPlatformBaseDomain = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
IsActive = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
updated_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'")
},
constraints: table =>
{
table.PrimaryKey("PK_tenant_domains", x => x.Domain);
table.ForeignKey(
name: "FK_tenant_domains_tenants_TenantKey",
column: x => x.TenantKey,
principalSchema: "meta",
principalTable: "tenants",
principalColumn: "TenantKey",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "user_external_accounts",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "nullif(current_setting('app.tenant_id', true),'')::uuid"),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Provider = table.Column<int>(type: "integer", nullable: false),
Subject = table.Column<string>(type: "text", nullable: false),
Email = table.Column<string>(type: "text", nullable: true),
LinkedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"),
created_by = table.Column<string>(type: "text", nullable: true),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
updated_by = table.Column<string>(type: "text", nullable: true),
is_deleted = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false)
},
constraints: table =>
{
table.PrimaryKey("PK_user_external_accounts", x => x.Id);
table.CheckConstraint("ck_user_external_accounts_tenant_not_null", "tenant_id is not null");
table.ForeignKey(
name: "FK_user_external_accounts_users_UserId",
column: x => x.UserId,
principalTable: "users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "user_identities",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "nullif(current_setting('app.tenant_id', true),'')::uuid"),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Type = table.Column<int>(type: "integer", nullable: false),
Identifier = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
IsPrimary = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
VerifiedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"),
created_by = table.Column<string>(type: "text", nullable: true),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
updated_by = table.Column<string>(type: "text", nullable: true),
is_deleted = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false)
},
constraints: table =>
{
table.PrimaryKey("PK_user_identities", x => x.Id);
table.CheckConstraint("ck_user_identities_tenant_not_null", "tenant_id is not null");
table.ForeignKey(
name: "FK_user_identities_users_UserId",
column: x => x.UserId,
principalTable: "users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "user_mfa_factors",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "nullif(current_setting('app.tenant_id', true),'')::uuid"),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Type = table.Column<int>(type: "integer", nullable: false),
Label = table.Column<string>(type: "text", nullable: true),
Secret = table.Column<string>(type: "text", nullable: true),
PhoneE164 = table.Column<string>(type: "text", nullable: true),
Email = table.Column<string>(type: "text", nullable: true),
PublicKey = table.Column<string>(type: "text", nullable: true),
CredentialId = table.Column<string>(type: "text", nullable: true),
Enabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
AddedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
LastUsedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"),
created_by = table.Column<string>(type: "text", nullable: true),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
updated_by = table.Column<string>(type: "text", nullable: true),
is_deleted = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false)
},
constraints: table =>
{
table.PrimaryKey("PK_user_mfa_factors", x => x.Id);
table.CheckConstraint("ck_user_mfa_factors_tenant_not_null", "tenant_id is not null");
table.ForeignKey(
name: "FK_user_mfa_factors_users_UserId",
column: x => x.UserId,
principalTable: "users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "user_password_histories",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "nullif(current_setting('app.tenant_id', true),'')::uuid"),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
PasswordHash = table.Column<string>(type: "text", nullable: false),
ChangedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"),
created_by = table.Column<string>(type: "text", nullable: true),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
updated_by = table.Column<string>(type: "text", nullable: true),
is_deleted = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false)
},
constraints: table =>
{
table.PrimaryKey("PK_user_password_histories", x => x.Id);
table.CheckConstraint("ck_user_password_histories_tenant_not_null", "tenant_id is not null");
table.ForeignKey(
name: "FK_user_password_histories_users_UserId",
column: x => x.UserId,
principalTable: "users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "user_profiles",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "nullif(current_setting('app.tenant_id', true),'')::uuid"),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
FirstName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
LastName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
MiddleName = table.Column<string>(type: "text", nullable: true),
Nickname = table.Column<string>(type: "text", nullable: true),
DateOfBirth = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
Gender = table.Column<int>(type: "integer", nullable: true),
DepartmentId = table.Column<Guid>(type: "uuid", nullable: true),
PositionId = table.Column<Guid>(type: "uuid", nullable: true),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"),
created_by = table.Column<string>(type: "text", nullable: true),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
updated_by = table.Column<string>(type: "text", nullable: true),
is_deleted = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false)
},
constraints: table =>
{
table.PrimaryKey("PK_user_profiles", x => x.Id);
table.CheckConstraint("ck_user_profiles_tenant_not_null", "tenant_id is not null");
table.ForeignKey(
name: "FK_user_profiles_departments_DepartmentId",
column: x => x.DepartmentId,
principalTable: "departments",
principalColumn: "Id");
table.ForeignKey(
name: "FK_user_profiles_positions_PositionId",
column: x => x.PositionId,
principalTable: "positions",
principalColumn: "Id");
table.ForeignKey(
name: "FK_user_profiles_users_UserId",
column: x => x.UserId,
principalTable: "users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "user_roles",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "nullif(current_setting('app.tenant_id', true),'')::uuid"),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
RoleId = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"),
created_by = table.Column<string>(type: "text", nullable: true),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
updated_by = table.Column<string>(type: "text", nullable: true),
is_deleted = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false)
},
constraints: table =>
{
table.PrimaryKey("PK_user_roles", x => x.Id);
table.CheckConstraint("ck_user_roles_tenant_not_null", "tenant_id is not null");
table.ForeignKey(
name: "FK_user_roles_roles_RoleId",
column: x => x.RoleId,
principalTable: "roles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_user_roles_users_UserId",
column: x => x.UserId,
principalTable: "users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "user_sessions",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "nullif(current_setting('app.tenant_id', true),'')::uuid"),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
RefreshTokenHash = table.Column<string>(type: "text", nullable: false),
IssuedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
ExpiresAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
RevokedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
DeviceId = table.Column<string>(type: "text", nullable: true),
UserAgent = table.Column<string>(type: "text", nullable: true),
IpAddress = table.Column<string>(type: "text", nullable: true),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"),
created_by = table.Column<string>(type: "text", nullable: true),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
updated_by = table.Column<string>(type: "text", nullable: true),
is_deleted = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false)
},
constraints: table =>
{
table.PrimaryKey("PK_user_sessions", x => x.Id);
table.CheckConstraint("ck_user_sessions_tenant_not_null", "tenant_id is not null");
table.ForeignKey(
name: "FK_user_sessions_users_UserId",
column: x => x.UserId,
principalTable: "users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "emergency_contacts",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "nullif(current_setting('app.tenant_id', true),'')::uuid"),
UserProfileId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
Relationship = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
Phone = table.Column<string>(type: "text", nullable: true),
Email = table.Column<string>(type: "text", nullable: true),
IsPrimary = table.Column<bool>(type: "boolean", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"),
created_by = table.Column<string>(type: "text", nullable: true),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
updated_by = table.Column<string>(type: "text", nullable: true),
is_deleted = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false)
},
constraints: table =>
{
table.PrimaryKey("PK_emergency_contacts", x => x.Id);
table.CheckConstraint("ck_emergency_contacts_tenant_not_null", "tenant_id is not null");
table.ForeignKey(
name: "FK_emergency_contacts_user_profiles_UserProfileId",
column: x => x.UserProfileId,
principalTable: "user_profiles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "employee_addresses",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "nullif(current_setting('app.tenant_id', true),'')::uuid"),
UserProfileId = table.Column<Guid>(type: "uuid", nullable: false),
Type = table.Column<int>(type: "integer", nullable: false),
Line1 = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
Line2 = table.Column<string>(type: "text", nullable: true),
City = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
State = table.Column<string>(type: "text", nullable: true),
PostalCode = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
Country = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
IsPrimary = table.Column<bool>(type: "boolean", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"),
created_by = table.Column<string>(type: "text", nullable: true),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
updated_by = table.Column<string>(type: "text", nullable: true),
is_deleted = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false)
},
constraints: table =>
{
table.PrimaryKey("PK_employee_addresses", x => x.Id);
table.CheckConstraint("ck_employee_addresses_tenant_not_null", "tenant_id is not null");
table.ForeignKey(
name: "FK_employee_addresses_user_profiles_UserProfileId",
column: x => x.UserProfileId,
principalTable: "user_profiles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "employee_bank_accounts",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "nullif(current_setting('app.tenant_id', true),'')::uuid"),
UserProfileId = table.Column<Guid>(type: "uuid", nullable: false),
BankName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
AccountNumber = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
AccountHolder = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
Branch = table.Column<string>(type: "text", nullable: true),
Note = table.Column<string>(type: "text", nullable: true),
IsPrimary = table.Column<bool>(type: "boolean", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"),
created_by = table.Column<string>(type: "text", nullable: true),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
updated_by = table.Column<string>(type: "text", nullable: true),
is_deleted = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false)
},
constraints: table =>
{
table.PrimaryKey("PK_employee_bank_accounts", x => x.Id);
table.CheckConstraint("ck_employee_bank_accounts_tenant_not_null", "tenant_id is not null");
table.ForeignKey(
name: "FK_employee_bank_accounts_user_profiles_UserProfileId",
column: x => x.UserProfileId,
principalTable: "user_profiles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "employments",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "nullif(current_setting('app.tenant_id', true),'')::uuid"),
UserProfileId = table.Column<Guid>(type: "uuid", nullable: false),
EmploymentType = table.Column<int>(type: "integer", nullable: false),
StartDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
EndDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
DepartmentId = table.Column<Guid>(type: "uuid", nullable: true),
PositionId = table.Column<Guid>(type: "uuid", nullable: true),
ManagerUserId = table.Column<Guid>(type: "uuid", nullable: true),
WorkEmail = table.Column<string>(type: "text", nullable: true),
WorkPhone = table.Column<string>(type: "text", nullable: true),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"),
created_by = table.Column<string>(type: "text", nullable: true),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
updated_by = table.Column<string>(type: "text", nullable: true),
is_deleted = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false)
},
constraints: table =>
{
table.PrimaryKey("PK_employments", x => x.Id);
table.CheckConstraint("ck_employments_tenant_not_null", "tenant_id is not null");
table.ForeignKey(
name: "FK_employments_departments_DepartmentId",
column: x => x.DepartmentId,
principalTable: "departments",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_employments_positions_PositionId",
column: x => x.PositionId,
principalTable: "positions",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_employments_user_profiles_UserProfileId",
column: x => x.UserProfileId,
principalTable: "user_profiles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_departments_ParentDepartmentId",
table: "departments",
column: "ParentDepartmentId");
migrationBuilder.CreateIndex(
name: "IX_departments_tenant_id",
table: "departments",
column: "tenant_id");
migrationBuilder.CreateIndex(
name: "IX_departments_tenant_id_Code",
table: "departments",
columns: new[] { "tenant_id", "Code" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_emergency_contacts_tenant_id",
table: "emergency_contacts",
column: "tenant_id");
migrationBuilder.CreateIndex(
name: "IX_emergency_contacts_tenant_id_UserProfileId_IsPrimary",
table: "emergency_contacts",
columns: new[] { "tenant_id", "UserProfileId", "IsPrimary" });
migrationBuilder.CreateIndex(
name: "IX_emergency_contacts_UserProfileId",
table: "emergency_contacts",
column: "UserProfileId");
migrationBuilder.CreateIndex(
name: "IX_employee_addresses_tenant_id",
table: "employee_addresses",
column: "tenant_id");
migrationBuilder.CreateIndex(
name: "IX_employee_addresses_tenant_id_UserProfileId_IsPrimary",
table: "employee_addresses",
columns: new[] { "tenant_id", "UserProfileId", "IsPrimary" });
migrationBuilder.CreateIndex(
name: "IX_employee_addresses_UserProfileId",
table: "employee_addresses",
column: "UserProfileId");
migrationBuilder.CreateIndex(
name: "IX_employee_bank_accounts_tenant_id",
table: "employee_bank_accounts",
column: "tenant_id");
migrationBuilder.CreateIndex(
name: "IX_employee_bank_accounts_tenant_id_UserProfileId_IsPrimary",
table: "employee_bank_accounts",
columns: new[] { "tenant_id", "UserProfileId", "IsPrimary" });
migrationBuilder.CreateIndex(
name: "IX_employee_bank_accounts_UserProfileId",
table: "employee_bank_accounts",
column: "UserProfileId");
migrationBuilder.CreateIndex(
name: "IX_employments_DepartmentId",
table: "employments",
column: "DepartmentId");
migrationBuilder.CreateIndex(
name: "IX_employments_PositionId",
table: "employments",
column: "PositionId");
migrationBuilder.CreateIndex(
name: "IX_employments_tenant_id",
table: "employments",
column: "tenant_id");
migrationBuilder.CreateIndex(
name: "IX_employments_tenant_id_UserProfileId_StartDate",
table: "employments",
columns: new[] { "tenant_id", "UserProfileId", "StartDate" });
migrationBuilder.CreateIndex(
name: "IX_employments_UserProfileId",
table: "employments",
column: "UserProfileId");
migrationBuilder.CreateIndex(
name: "IX_permissions_tenant_id",
table: "permissions",
column: "tenant_id");
migrationBuilder.CreateIndex(
name: "IX_permissions_tenant_id_Code",
table: "permissions",
columns: new[] { "tenant_id", "Code" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_positions_tenant_id",
table: "positions",
column: "tenant_id");
migrationBuilder.CreateIndex(
name: "IX_positions_tenant_id_Code",
table: "positions",
columns: new[] { "tenant_id", "Code" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_role_permissions_PermissionId",
table: "role_permissions",
column: "PermissionId");
migrationBuilder.CreateIndex(
name: "IX_role_permissions_RoleId",
table: "role_permissions",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "IX_role_permissions_tenant_id",
table: "role_permissions",
column: "tenant_id");
migrationBuilder.CreateIndex(
name: "IX_role_permissions_tenant_id_RoleId_PermissionId",
table: "role_permissions",
columns: new[] { "tenant_id", "RoleId", "PermissionId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_roles_tenant_id",
table: "roles",
column: "tenant_id");
migrationBuilder.CreateIndex(
name: "IX_roles_tenant_id_Code",
table: "roles",
columns: new[] { "tenant_id", "Code" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_tenant_domains_IsActive",
schema: "meta",
table: "tenant_domains",
column: "IsActive");
migrationBuilder.CreateIndex(
name: "IX_tenant_domains_IsPlatformBaseDomain",
schema: "meta",
table: "tenant_domains",
column: "IsPlatformBaseDomain");
migrationBuilder.CreateIndex(
name: "IX_tenant_domains_TenantKey",
schema: "meta",
table: "tenant_domains",
column: "TenantKey");
migrationBuilder.CreateIndex(
name: "IX_tenants_IsActive",
schema: "meta",
table: "tenants",
column: "IsActive");
migrationBuilder.CreateIndex(
name: "IX_user_external_accounts_tenant_id",
table: "user_external_accounts",
column: "tenant_id");
migrationBuilder.CreateIndex(
name: "IX_user_external_accounts_tenant_id_Provider_Subject",
table: "user_external_accounts",
columns: new[] { "tenant_id", "Provider", "Subject" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_user_external_accounts_UserId",
table: "user_external_accounts",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_user_identities_tenant_id",
table: "user_identities",
column: "tenant_id");
migrationBuilder.CreateIndex(
name: "IX_user_identities_tenant_id_Type_Identifier",
table: "user_identities",
columns: new[] { "tenant_id", "Type", "Identifier" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_user_identities_UserId",
table: "user_identities",
column: "UserId");
migrationBuilder.CreateIndex(
name: "ix_user_identity_primary_per_type",
table: "user_identities",
columns: new[] { "tenant_id", "UserId", "Type", "IsPrimary" });
migrationBuilder.CreateIndex(
name: "IX_user_mfa_factors_tenant_id",
table: "user_mfa_factors",
column: "tenant_id");
migrationBuilder.CreateIndex(
name: "IX_user_mfa_factors_tenant_id_UserId",
table: "user_mfa_factors",
columns: new[] { "tenant_id", "UserId" });
migrationBuilder.CreateIndex(
name: "IX_user_mfa_factors_UserId",
table: "user_mfa_factors",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_user_password_histories_tenant_id",
table: "user_password_histories",
column: "tenant_id");
migrationBuilder.CreateIndex(
name: "IX_user_password_histories_tenant_id_UserId_ChangedAt",
table: "user_password_histories",
columns: new[] { "tenant_id", "UserId", "ChangedAt" });
migrationBuilder.CreateIndex(
name: "IX_user_password_histories_UserId",
table: "user_password_histories",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_user_profiles_DepartmentId",
table: "user_profiles",
column: "DepartmentId");
migrationBuilder.CreateIndex(
name: "IX_user_profiles_PositionId",
table: "user_profiles",
column: "PositionId");
migrationBuilder.CreateIndex(
name: "IX_user_profiles_tenant_id",
table: "user_profiles",
column: "tenant_id");
migrationBuilder.CreateIndex(
name: "IX_user_profiles_tenant_id_UserId",
table: "user_profiles",
columns: new[] { "tenant_id", "UserId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_user_profiles_UserId",
table: "user_profiles",
column: "UserId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_user_roles_RoleId",
table: "user_roles",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "IX_user_roles_tenant_id",
table: "user_roles",
column: "tenant_id");
migrationBuilder.CreateIndex(
name: "IX_user_roles_tenant_id_UserId_RoleId",
table: "user_roles",
columns: new[] { "tenant_id", "UserId", "RoleId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_user_roles_UserId",
table: "user_roles",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_user_sessions_tenant_id",
table: "user_sessions",
column: "tenant_id");
migrationBuilder.CreateIndex(
name: "IX_user_sessions_tenant_id_DeviceId",
table: "user_sessions",
columns: new[] { "tenant_id", "DeviceId" });
migrationBuilder.CreateIndex(
name: "IX_user_sessions_tenant_id_UserId",
table: "user_sessions",
columns: new[] { "tenant_id", "UserId" });
migrationBuilder.CreateIndex(
name: "IX_user_sessions_UserId",
table: "user_sessions",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_users_tenant_id",
table: "users",
column: "tenant_id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "emergency_contacts");
migrationBuilder.DropTable(
name: "employee_addresses");
migrationBuilder.DropTable(
name: "employee_bank_accounts");
migrationBuilder.DropTable(
name: "employments");
migrationBuilder.DropTable(
name: "role_permissions");
migrationBuilder.DropTable(
name: "tenant_domains",
schema: "meta");
migrationBuilder.DropTable(
name: "user_external_accounts");
migrationBuilder.DropTable(
name: "user_identities");
migrationBuilder.DropTable(
name: "user_mfa_factors");
migrationBuilder.DropTable(
name: "user_password_histories");
migrationBuilder.DropTable(
name: "user_roles");
migrationBuilder.DropTable(
name: "user_sessions");
migrationBuilder.DropTable(
name: "user_profiles");
migrationBuilder.DropTable(
name: "permissions");
migrationBuilder.DropTable(
name: "tenants",
schema: "meta");
migrationBuilder.DropTable(
name: "roles");
migrationBuilder.DropTable(
name: "departments");
migrationBuilder.DropTable(
name: "positions");
migrationBuilder.DropTable(
name: "users");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,10 @@
namespace AMREZ.EOP.Infrastructures.Options;
public sealed class AuthOptions
{
public string DefaultConnection { get; set; } = default!;
public bool UseSchemaPerTenant { get; set; } = true;
public string StorageBackend { get; set; } = "ef";
public string? RedisConnection { get; set; }
public int RedisDb { get; set; } = 0;
}

View File

@@ -0,0 +1,172 @@
using AMREZ.EOP.Abstractions.Infrastructures.Repositories;
using AMREZ.EOP.Abstractions.Storage;
using AMREZ.EOP.Domain.Entities.Tenancy;
using AMREZ.EOP.Infrastructures.Data;
using AMREZ.EOP.Infrastructures.Options;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace AMREZ.EOP.Infrastructures.Repositories;
public sealed class TenantRepository : ITenantRepository
{
private readonly IDbScope _scope;
public TenantRepository(IDbScope scope) => _scope = scope;
private AppDbContext Db() => _scope.Get<AppDbContext>();
private static string Norm(string s) => (s ?? string.Empty).Trim().ToLowerInvariant();
public Task<bool> TenantExistsAsync(string tenantKey, CancellationToken ct = default)
{
var key = Norm(tenantKey);
return Db().Set<TenantConfig>().AsNoTracking().AnyAsync(x => x.TenantKey == key, ct);
}
public Task<TenantConfig?> GetAsync(string tenantKey, CancellationToken ct = default)
{
var key = Norm(tenantKey);
return Db().Set<TenantConfig>().AsNoTracking().FirstOrDefaultAsync(x => x.TenantKey == key, ct);
}
public async Task<IReadOnlyList<TenantConfig>> ListTenantsAsync(CancellationToken ct = default)
=> await Db().Set<TenantConfig>().AsNoTracking().OrderBy(x => x.TenantKey).ToListAsync(ct);
public async Task<bool> CreateAsync(TenantConfig row, CancellationToken ct = default)
{
row.TenantKey = Norm(row.TenantKey);
row.Schema = string.IsNullOrWhiteSpace(row.Schema) ? null : row.Schema!.Trim();
row.ConnectionString = string.IsNullOrWhiteSpace(row.ConnectionString) ? null : row.ConnectionString!.Trim();
row.UpdatedAtUtc = DateTimeOffset.UtcNow;
await Db().Set<TenantConfig>().AddAsync(row, ct);
return await Db().SaveChangesAsync(ct) > 0;
}
public async Task<bool> UpdateAsync(TenantConfig row, DateTimeOffset? ifUnmodifiedSince, CancellationToken ct = default)
{
var key = Norm(row.TenantKey);
var db = Db();
var cur = await db.Set<TenantConfig>().FirstOrDefaultAsync(x => x.TenantKey == key, ct);
if (cur is null) return false;
if (ifUnmodifiedSince.HasValue && cur.UpdatedAtUtc > ifUnmodifiedSince.Value)
return false; // 412
cur.Schema = row.Schema is null ? cur.Schema : (string.IsNullOrWhiteSpace(row.Schema) ? null : row.Schema.Trim());
cur.ConnectionString = row.ConnectionString is null ? cur.ConnectionString : (string.IsNullOrWhiteSpace(row.ConnectionString) ? null : row.ConnectionString.Trim());
cur.Mode = row.Mode;
cur.IsActive = row.IsActive;
cur.UpdatedAtUtc = DateTimeOffset.UtcNow;
return await db.SaveChangesAsync(ct) > 0;
}
public async Task<bool> DeleteAsync(string tenantKey, CancellationToken ct = default)
{
var key = Norm(tenantKey);
var db = Db();
var t = await db.Set<TenantConfig>().FirstOrDefaultAsync(x => x.TenantKey == key, ct);
if (t is null) return false;
// ❌ ไม่ลบนะ — ✅ deactivate
if (t.IsActive)
{
t.IsActive = false;
t.UpdatedAtUtc = DateTimeOffset.UtcNow;
}
var domains = await db.Set<TenantDomain>()
.Where(d => d.TenantKey == key && d.IsActive)
.ToListAsync(ct);
foreach (var d in domains)
{
d.IsActive = false;
d.UpdatedAtUtc = DateTimeOffset.UtcNow;
}
return await db.SaveChangesAsync(ct) > 0;
}
public async Task<bool> MapDomainAsync(string domain, string tenantKey, CancellationToken ct = default)
{
var d = Norm(domain);
var key = Norm(tenantKey);
if (!await Db().Set<TenantConfig>().AnyAsync(x => x.TenantKey == key && x.IsActive, ct))
return false;
var db = Db();
var ex = await db.Set<TenantDomain>().FirstOrDefaultAsync(x => x.Domain == d, ct);
if (ex is null)
await db.Set<TenantDomain>().AddAsync(new TenantDomain { Domain = d, TenantKey = key, IsPlatformBaseDomain = false, IsActive = true, UpdatedAtUtc = DateTimeOffset.UtcNow }, ct);
else
{
ex.TenantKey = key;
ex.IsPlatformBaseDomain = false;
ex.IsActive = true;
ex.UpdatedAtUtc = DateTimeOffset.UtcNow;
}
return await db.SaveChangesAsync(ct) > 0;
}
public async Task<bool> UnmapDomainAsync(string domain, CancellationToken ct = default)
{
var d = Norm(domain);
var db = Db();
var ex = await db.Set<TenantDomain>().FirstOrDefaultAsync(x => x.Domain == d, ct);
if (ex is null) return false;
ex.IsActive = false;
ex.TenantKey = null;
ex.IsPlatformBaseDomain = false;
ex.UpdatedAtUtc = DateTimeOffset.UtcNow;
return await db.SaveChangesAsync(ct) > 0;
}
public async Task<IReadOnlyList<TenantDomain>> ListDomainsAsync(string? tenantKey, CancellationToken ct = default)
{
var db = Db();
var q = db.Set<TenantDomain>().AsNoTracking();
if (!string.IsNullOrWhiteSpace(tenantKey))
{
var key = Norm(tenantKey!);
q = q.Where(x => x.TenantKey == key || x.IsPlatformBaseDomain);
}
return await q.OrderBy(x => x.Domain).ToListAsync(ct);
}
public async Task<bool> AddBaseDomainAsync(string baseDomain, CancellationToken ct = default)
{
var d = Norm(baseDomain);
var db = Db();
var ex = await db.Set<TenantDomain>().FirstOrDefaultAsync(x => x.Domain == d, ct);
if (ex is null)
await db.Set<TenantDomain>().AddAsync(new TenantDomain { Domain = d, TenantKey = null, IsPlatformBaseDomain = true, IsActive = true, UpdatedAtUtc = DateTimeOffset.UtcNow }, ct);
else
{
ex.TenantKey = null;
ex.IsPlatformBaseDomain = true;
ex.IsActive = true;
ex.UpdatedAtUtc = DateTimeOffset.UtcNow;
}
return await db.SaveChangesAsync(ct) > 0;
}
public async Task<bool> RemoveBaseDomainAsync(string baseDomain, CancellationToken ct = default)
{
var d = Norm(baseDomain);
var db = Db();
var ex = await db.Set<TenantDomain>()
.FirstOrDefaultAsync(x => x.Domain == d && x.IsPlatformBaseDomain, ct);
if (ex is null) return false;
ex.IsActive = false;
ex.UpdatedAtUtc = DateTimeOffset.UtcNow;
return await db.SaveChangesAsync(ct) > 0;
}
}

View File

@@ -0,0 +1,144 @@
using AMREZ.EOP.Abstractions.Applications.Tenancy;
using AMREZ.EOP.Abstractions.Infrastructures.Repositories;
using AMREZ.EOP.Abstractions.Storage;
using AMREZ.EOP.Domain.Entities.HumanResources;
using AMREZ.EOP.Infrastructures.Data;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
namespace AMREZ.EOP.Infrastructures.Repositories;
public class UserProfileRepository : IUserProfileRepository
{
private readonly IDbScope _scope;
private readonly ITenantResolver _tenantResolver;
private readonly IHttpContextAccessor _http;
public UserProfileRepository(IDbScope scope, ITenantResolver tenantResolver, IHttpContextAccessor http)
{ _scope = scope; _tenantResolver = tenantResolver; _http = http; }
private Guid TenantId()
{
var http = _http.HttpContext;
var tc = http is not null ? _tenantResolver.Resolve(http) : null;
return Guid.TryParse(tc?.Id, out var g) ? g : Guid.Empty;
}
public async Task<UserProfile?> GetByUserIdAsync(Guid userId, CancellationToken ct = default)
{
var db = _scope.Get<AppDbContext>();
var tid = TenantId();
return await db.UserProfiles.FirstOrDefaultAsync(p => p.TenantId == tid && p.UserId == userId, ct);
}
public async Task UpsertAsync(UserProfile profile, CancellationToken ct = default)
{
var db = _scope.Get<AppDbContext>();
var tid = TenantId();
var existing = await db.UserProfiles.FirstOrDefaultAsync(p => p.TenantId == tid && p.UserId == profile.UserId, ct);
if (existing is null)
{
profile.TenantId = tid;
await db.UserProfiles.AddAsync(profile, ct);
}
else
{
existing.FirstName = profile.FirstName;
existing.LastName = profile.LastName;
existing.MiddleName = profile.MiddleName;
existing.Nickname = profile.Nickname;
existing.DateOfBirth = profile.DateOfBirth;
existing.Gender = profile.Gender;
}
await db.SaveChangesAsync(ct);
}
public async Task<Employment> AddEmploymentAsync(Employment e, CancellationToken ct = default)
{
var db = _scope.Get<AppDbContext>();
e.TenantId = TenantId();
await db.Employments.AddAsync(e, ct);
await db.SaveChangesAsync(ct);
return e;
}
public async Task EndEmploymentAsync(Guid employmentId, DateTime endDate, CancellationToken ct = default)
{
var db = _scope.Get<AppDbContext>();
var tid = TenantId();
var e = await db.Employments.FirstOrDefaultAsync(x => x.TenantId == tid && x.Id == employmentId, ct);
if (e is null) return;
e.EndDate = endDate;
await db.SaveChangesAsync(ct);
}
public async Task<EmployeeAddress> AddAddressAsync(EmployeeAddress a, CancellationToken ct = default)
{
var db = _scope.Get<AppDbContext>();
a.TenantId = TenantId();
await db.EmployeeAddresses.AddAsync(a, ct);
await db.SaveChangesAsync(ct);
return a;
}
public async Task SetPrimaryAddressAsync(Guid userProfileId, Guid addressId, CancellationToken ct = default)
{
var db = _scope.Get<AppDbContext>();
var tid = TenantId();
var all = await db.EmployeeAddresses.Where(x => x.TenantId == tid && x.UserProfileId == userProfileId).ToListAsync(ct);
foreach (var x in all) x.IsPrimary = false;
var target = all.FirstOrDefault(x => x.Id == addressId);
if (target is not null) target.IsPrimary = true;
await db.SaveChangesAsync(ct);
}
public async Task<EmergencyContact> AddEmergencyContactAsync(EmergencyContact c, CancellationToken ct = default)
{
var db = _scope.Get<AppDbContext>();
c.TenantId = TenantId();
await db.EmergencyContacts.AddAsync(c, ct);
await db.SaveChangesAsync(ct);
return c;
}
public async Task SetPrimaryEmergencyContactAsync(Guid userProfileId, Guid contactId, CancellationToken ct = default)
{
var db = _scope.Get<AppDbContext>();
var tid = TenantId();
var all = await db.EmergencyContacts.Where(x => x.TenantId == tid && x.UserProfileId == userProfileId).ToListAsync(ct);
foreach (var x in all) x.IsPrimary = false;
var target = all.FirstOrDefault(x => x.Id == contactId);
if (target is not null) target.IsPrimary = true;
await db.SaveChangesAsync(ct);
}
public async Task<EmployeeBankAccount> AddBankAccountAsync(EmployeeBankAccount b, CancellationToken ct = default)
{
var db = _scope.Get<AppDbContext>();
b.TenantId = TenantId();
await db.EmployeeBankAccounts.AddAsync(b, ct);
await db.SaveChangesAsync(ct);
return b;
}
public async Task SetPrimaryBankAccountAsync(Guid userProfileId, Guid bankAccountId, CancellationToken ct = default)
{
var db = _scope.Get<AppDbContext>();
var tid = TenantId();
var all = await db.EmployeeBankAccounts.Where(x => x.TenantId == tid && x.UserProfileId == userProfileId).ToListAsync(ct);
foreach (var x in all) x.IsPrimary = false;
var target = all.FirstOrDefault(x => x.Id == bankAccountId);
if (target is not null) target.IsPrimary = true;
await db.SaveChangesAsync(ct);
}
}

View File

@@ -0,0 +1,329 @@
using System.Text.Json;
using AMREZ.EOP.Abstractions.Applications.Tenancy;
using AMREZ.EOP.Abstractions.Infrastructures.Repositories;
using AMREZ.EOP.Abstractions.Storage;
using AMREZ.EOP.Domain.Entities.Authentications;
using AMREZ.EOP.Domain.Shared._Users;
using AMREZ.EOP.Infrastructures.Data;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using StackExchange.Redis;
namespace AMREZ.EOP.Infrastructures.Repositories;
public class UserRepository : IUserRepository
{
private readonly IDbScope _scope;
private readonly IConnectionMultiplexer? _redis;
private readonly ITenantResolver _tenantResolver;
private readonly IHttpContextAccessor _http;
public UserRepository(
IDbScope scope,
IConnectionMultiplexer? redis,
ITenantResolver tenantResolver,
IHttpContextAccessor http)
{
_scope = scope;
_redis = redis; // null = ไม่มี/ต่อไม่ได้ → ข้าม cache
_tenantResolver = tenantResolver;
_http = http;
}
private Guid TenantId()
{
var http = _http.HttpContext;
var tc = http is not null ? _tenantResolver.Resolve(http) : null;
return Guid.TryParse(tc?.Id, out var g) ? g : Guid.Empty;
}
private static string IdentityEmailKey(string tenantId, string email)
=> $"eop:{tenantId}:user:identity:email:{email.Trim().ToLowerInvariant()}";
private static string UserIdKey(string tenantId, Guid id)
=> $"eop:{tenantId}:user:id:{id:N}";
public async Task<User?> FindByIdAsync(Guid userId, CancellationToken ct = default)
{
var db = _scope.Get<AppDbContext>();
var tid = TenantId();
return await db.Users.AsNoTracking()
.FirstOrDefaultAsync(u => u.TenantId == tid && u.Id == userId, ct);
}
public async Task<User?> FindActiveByEmailAsync(string email, CancellationToken ct = default)
{
var tid = TenantId();
var tidStr = tid.ToString();
var r = _redis?.GetDatabase();
// 1) Redis
if (r is not null)
{
try
{
var cached = await r.StringGetAsync(IdentityEmailKey(tidStr, email));
if (cached.HasValue)
{
var hit = JsonSerializer.Deserialize<User>(cached!);
if (hit?.IsActive == true) return hit;
}
}
catch { /* ignore → query DB */ }
}
// 2) EF: join identities
var db = _scope.Get<AppDbContext>();
var norm = email.Trim().ToLowerInvariant();
var user = await db.Users
.AsNoTracking()
.Where(u => u.TenantId == tid && u.IsActive)
.Where(u => db.UserIdentities.Any(i =>
i.TenantId == tid &&
i.UserId == u.Id &&
i.Type == IdentityType.Email &&
i.Identifier == norm))
.FirstOrDefaultAsync(ct);
// 3) cache
if (user is not null && r is not null)
{
try
{
var payload = JsonSerializer.Serialize(user);
var ttl = TimeSpan.FromMinutes(5);
await r.StringSetAsync(IdentityEmailKey(tidStr, norm), payload, ttl);
await r.StringSetAsync(UserIdKey(tidStr, user.Id), payload, ttl);
}
catch { /* ignore */ }
}
return user;
}
public async Task<bool> EmailExistsAsync(string email, CancellationToken ct = default)
{
var db = _scope.Get<AppDbContext>();
var tid = TenantId();
var norm = email.Trim().ToLowerInvariant();
return await db.UserIdentities.AsNoTracking()
.AnyAsync(i => i.TenantId == tid && i.Type == IdentityType.Email && i.Identifier == norm, ct);
}
public async Task AddAsync(User user, CancellationToken ct = default)
{
var db = _scope.Get<AppDbContext>();
user.TenantId = TenantId();
await db.Users.AddAsync(user, ct);
await db.SaveChangesAsync(ct);
// cache
var r = _redis?.GetDatabase();
if (r is not null)
{
try
{
var tid = user.TenantId.ToString();
var payload = JsonSerializer.Serialize(user);
var ttl = TimeSpan.FromMinutes(5);
await r.StringSetAsync(UserIdKey(tid, user.Id), payload, ttl);
var email = user.Identities
.FirstOrDefault(i => i.Type == IdentityType.Email && i.IsPrimary)?.Identifier;
if (!string.IsNullOrWhiteSpace(email))
await r.StringSetAsync(IdentityEmailKey(tid, email!), payload, ttl);
}
catch { /* ignore */ }
}
}
// ========= Identities =========
// (1) IMPLEMENTED: AddIdentityAsync
public async Task AddIdentityAsync(Guid userId, IdentityType type, string identifier, bool isPrimary, CancellationToken ct = default)
{
var db = _scope.Get<AppDbContext>();
var tid = TenantId();
var norm = identifier.Trim().ToLowerInvariant();
if (isPrimary)
{
var existingPrimary = await db.UserIdentities
.Where(i => i.TenantId == tid && i.UserId == userId && i.Type == type && i.IsPrimary)
.ToListAsync(ct);
foreach (var x in existingPrimary) x.IsPrimary = false;
}
var entity = new UserIdentity
{
TenantId = tid,
UserId = userId,
Type = type,
Identifier = norm,
IsPrimary = isPrimary
};
await db.UserIdentities.AddAsync(entity, ct);
await db.SaveChangesAsync(ct);
}
// (2) IMPLEMENTED: VerifyIdentityAsync
public async Task VerifyIdentityAsync(Guid userId, IdentityType type, string identifier, DateTimeOffset verifiedAt, CancellationToken ct = default)
{
var db = _scope.Get<AppDbContext>();
var tid = TenantId();
var norm = identifier.Trim().ToLowerInvariant();
var id = await db.UserIdentities
.FirstOrDefaultAsync(i =>
i.TenantId == tid &&
i.UserId == userId &&
i.Type == type &&
i.Identifier == norm, ct);
if (id is null) return;
id.VerifiedAt = verifiedAt;
await db.SaveChangesAsync(ct);
}
// (3) IMPLEMENTED: GetPrimaryIdentityAsync
public async Task<UserIdentity?> GetPrimaryIdentityAsync(Guid userId, IdentityType type, CancellationToken ct = default)
{
var db = _scope.Get<AppDbContext>();
var tid = TenantId();
return await db.UserIdentities.AsNoTracking()
.FirstOrDefaultAsync(i =>
i.TenantId == tid &&
i.UserId == userId &&
i.Type == type &&
i.IsPrimary, ct);
}
// ========= Password =========
public async Task ChangePasswordAsync(Guid userId, string newPasswordHash, CancellationToken ct = default)
{
var db = _scope.Get<AppDbContext>();
var tid = TenantId();
var user = await db.Users.FirstOrDefaultAsync(u => u.TenantId == tid && u.Id == userId, ct);
if (user is null) return;
user.PasswordHash = newPasswordHash;
user.SecurityStamp = Guid.NewGuid().ToString("N");
await db.SaveChangesAsync(ct);
}
public async Task AddPasswordHistoryAsync(Guid userId, string passwordHash, CancellationToken ct = default)
{
var db = _scope.Get<AppDbContext>();
var tid = TenantId();
var h = new UserPasswordHistory
{
TenantId = tid,
UserId = userId,
PasswordHash = passwordHash,
ChangedAt = DateTimeOffset.UtcNow
};
await db.UserPasswordHistories.AddAsync(h, ct);
await db.SaveChangesAsync(ct);
}
// ========= MFA =========
public async Task<UserMfaFactor> AddTotpFactorAsync(Guid userId, string label, string secret, CancellationToken ct = default)
{
var db = _scope.Get<AppDbContext>();
var tid = TenantId();
var f = new UserMfaFactor
{
TenantId = tid,
UserId = userId,
Type = MfaType.Totp,
Label = label,
Secret = secret,
Enabled = true,
AddedAt = DateTimeOffset.UtcNow
};
await db.UserMfaFactors.AddAsync(f, ct);
var u = await db.Users.FirstAsync(x => x.TenantId == tid && x.Id == userId, ct);
u.MfaEnabled = true;
await db.SaveChangesAsync(ct);
return f;
}
public async Task DisableMfaFactorAsync(Guid factorId, CancellationToken ct = default)
{
var db = _scope.Get<AppDbContext>();
var tid = TenantId();
var f = await db.UserMfaFactors.FirstOrDefaultAsync(x => x.TenantId == tid && x.Id == factorId, ct);
if (f is null) return;
f.Enabled = false;
var still = await db.UserMfaFactors.AnyAsync(x => x.TenantId == tid && x.UserId == f.UserId && x.Enabled, ct);
if (!still)
{
var u = await db.Users.FirstAsync(x => x.TenantId == tid && x.Id == f.UserId, ct);
u.MfaEnabled = false;
}
await db.SaveChangesAsync(ct);
}
public async Task<bool> HasAnyMfaAsync(Guid userId, CancellationToken ct = default)
{
var db = _scope.Get<AppDbContext>();
var tid = TenantId();
return await db.UserMfaFactors.AnyAsync(x => x.TenantId == tid && x.UserId == userId && x.Enabled, ct);
}
// ========= Sessions =========
public async Task<UserSession> CreateSessionAsync(UserSession session, CancellationToken ct = default)
{
var db = _scope.Get<AppDbContext>();
session.TenantId = TenantId();
await db.UserSessions.AddAsync(session, ct);
await db.SaveChangesAsync(ct);
return session;
}
public async Task<int> RevokeSessionAsync(Guid userId, Guid sessionId, CancellationToken ct = default)
{
var db = _scope.Get<AppDbContext>();
var tid = TenantId();
var s = await db.UserSessions.FirstOrDefaultAsync(x => x.TenantId == tid && x.Id == sessionId && x.UserId == userId, ct);
if (s is null) return 0;
s.RevokedAt = DateTimeOffset.UtcNow;
return await db.SaveChangesAsync(ct);
}
public async Task<int> RevokeAllSessionsAsync(Guid userId, CancellationToken ct = default)
{
var db = _scope.Get<AppDbContext>();
var tid = TenantId();
var sessions = await db.UserSessions
.Where(x => x.TenantId == tid && x.UserId == userId && x.RevokedAt == null)
.ToListAsync(ct);
foreach (var s in sessions) s.RevokedAt = DateTimeOffset.UtcNow;
return await db.SaveChangesAsync(ct);
}
}

View File

@@ -0,0 +1,61 @@
using System.Security.Cryptography;
using System.Text;
using AMREZ.EOP.Abstractions.Security;
namespace AMREZ.EOP.Infrastructures.Security;
public sealed class BcryptPasswordHasher : IPasswordHasher
{
private const int SaltSizeBytes = 16; // 128-bit salt
private const int KeySizeBytes = 32; // 256-bit key
private const int Iterations = 200_000; // 2e5 (แนะนำ >= 150k บน .NET 8/9)
public bool Verify(string plain, string hash)
{
if (plain is null || hash is null) return false;
var parts = hash.Split('$', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 4 || !parts[0].Equals("pbkdf2-sha256", StringComparison.OrdinalIgnoreCase))
return false;
if (!int.TryParse(parts[1], out var iters) || iters <= 0) return false;
byte[] salt, expected;
try
{
salt = Convert.FromBase64String(parts[2]);
expected = Convert.FromBase64String(parts[3]);
}
catch { return false; }
byte[] actual = Rfc2898DeriveBytes.Pbkdf2(
password: Encoding.UTF8.GetBytes(plain),
salt: salt,
iterations: iters,
hashAlgorithm: HashAlgorithmName.SHA256,
outputLength: expected.Length
);
return CryptographicOperations.FixedTimeEquals(actual, expected);
}
public string Hash(string plain)
{
if (plain is null) throw new ArgumentNullException(nameof(plain));
Span<byte> salt = stackalloc byte[SaltSizeBytes];
RandomNumberGenerator.Fill(salt);
byte[] key = Rfc2898DeriveBytes.Pbkdf2(
password: Encoding.UTF8.GetBytes(plain),
salt: salt.ToArray(),
iterations: Iterations,
hashAlgorithm: HashAlgorithmName.SHA256,
outputLength: KeySizeBytes
);
var saltB64 = Convert.ToBase64String(salt);
var keyB64 = Convert.ToBase64String(key);
return $"pbkdf2-sha256${Iterations}${saltB64}${keyB64}";
}
}

View File

@@ -0,0 +1,64 @@
using System.Collections.Concurrent;
using AMREZ.EOP.Abstractions.Applications.Tenancy;
using AMREZ.EOP.Abstractions.Storage;
using AMREZ.EOP.Infrastructures.Data;
using AMREZ.EOP.Infrastructures.Options;
using Microsoft.Extensions.Options;
using StackExchange.Redis;
using IDatabase = Microsoft.EntityFrameworkCore.Storage.IDatabase;
namespace AMREZ.EOP.Infrastructures.Storage;
public sealed class DbScope : IDbScope, IAsyncDisposable
{
private readonly ITenantDbContextFactory _factory;
private readonly IOptions<AuthOptions> _opts;
private readonly IConnectionMultiplexer? _redis;
private readonly ConcurrentDictionary<Type, object> _cache = new();
public DbScope(ITenantDbContextFactory factory, IOptions<AuthOptions> opts, IConnectionMultiplexer? redis = null)
{ _factory = factory; _opts = opts; _redis = redis; }
private ITenantContext? _tenant;
public void EnsureForTenant(ITenantContext tenant)
{
if (_tenant?.Id == tenant.Id) return;
_tenant = tenant;
_cache.Clear();
var ef = _factory.Create<AppDbContext>(tenant);
_cache[typeof(AppDbContext)] = ef;
if (_redis != null)
_cache[typeof(IDatabase)] = _redis.GetDatabase(_opts.Value.RedisDb);
}
public TContext Get<TContext>() where TContext : class
{
if (_tenant is null) throw new InvalidOperationException("Tenant not set. Call EnsureForTenant first.");
if (_cache.TryGetValue(typeof(TContext), out var obj)) return (TContext)obj;
if (typeof(TContext) == typeof(AppDbContext))
{
var ef = _factory.Create<AppDbContext>(_tenant);
_cache[typeof(AppDbContext)] = ef;
return (TContext)(object)ef;
}
if (typeof(TContext) == typeof(IDatabase))
{
if (_redis is null) throw new InvalidOperationException("Redis not configured");
var db = _redis.GetDatabase(_opts.Value.RedisDb);
_cache[typeof(IDatabase)] = db;
return (TContext)(object)db;
}
throw new NotSupportedException($"Unknown context type {typeof(TContext).Name}");
}
public async ValueTask DisposeAsync()
{
if (_cache.TryGetValue(typeof(AppDbContext), out var ef) && ef is AppDbContext db)
await db.DisposeAsync();
_cache.Clear();
}
}

View File

@@ -0,0 +1,98 @@
using System.Text.RegularExpressions;
using AMREZ.EOP.Abstractions.Applications.Tenancy;
using AMREZ.EOP.Contracts.DTOs.Authentications.Login;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
namespace AMREZ.EOP.Infrastructures.Tenancy;
public sealed class DefaultTenantResolver : ITenantResolver
{
private readonly TenantMap _map;
private readonly ILogger<DefaultTenantResolver>? _log;
// แพลตฟอร์มสลัก (ปรับตามระบบจริงได้) — ใช้ "public" เป็นค่าเริ่ม
private const string PlatformSlug = "public";
public DefaultTenantResolver(
TenantMap map,
ILogger<DefaultTenantResolver>? log = null
)
{
_map = map;
_log = log;
}
public ITenantContext? Resolve(HttpContext http, object? hint = null)
{
var cid = http.TraceIdentifier;
var path = http.Request.Path.Value ?? string.Empty;
// ✅ ทางลัด: ขอ platform ตรง ๆ ด้วย hint
if (hint is string hs && string.Equals(hs, "@platform", StringComparison.OrdinalIgnoreCase))
{
if (_map.TryGetBySlug(PlatformSlug, out var platform))
{
_log?.LogInformation("Resolved by hint=@platform -> {Slug} cid={Cid}", PlatformSlug, cid);
return platform;
}
_log?.LogWarning("Platform slug '{Slug}' not found in TenantMap cid={Cid}", PlatformSlug, cid);
return null;
}
if (path.StartsWith("/api/Tenancy", StringComparison.OrdinalIgnoreCase))
{
if (_map.TryGetBySlug(PlatformSlug, out var platform))
{
// เก็บ target tenant แยกไว้ให้ repo/usecase ใช้
var headerTenant = http.Request.Headers["X-Tenant"].ToString();
if (!string.IsNullOrWhiteSpace(headerTenant))
http.Items["TargetTenantKey"] = headerTenant.Trim().ToLowerInvariant();
_log?.LogInformation("Resolved CONTROL-PLANE -> platform:{Slug} (path={Path}) cid={Cid}", PlatformSlug, path, cid);
return platform;
}
_log?.LogWarning("CONTROL-PLANE path but platform slug '{Slug}' missing cid={Cid}", PlatformSlug, cid);
return null;
}
var header = http.Request.Headers["X-Tenant"].ToString();
if (!string.IsNullOrWhiteSpace(header) && _map.TryGetBySlug(header, out var byHeader))
{
_log?.LogInformation("Resolved by header: {Tenant} cid={Cid}", header, cid);
return byHeader;
}
var host = http.Request.Host.Host;
if (_map.TryGetByDomain(host, out var byDomain))
{
_log?.LogInformation("Resolved by domain: {Host} -> {TenantId} cid={Cid}", host, byDomain.Id, cid);
return byDomain;
}
var sub = GetSubdomain(host);
if (sub != null && _map.TryGetBySlug(sub, out var bySub))
{
_log?.LogInformation("Resolved by subdomain: {Sub} cid={Cid}", sub, cid);
return bySub;
}
if (hint is LoginRequest body && !string.IsNullOrWhiteSpace(body.Tenant) &&
_map.TryGetBySlug(body.Tenant!, out var byBody))
{
_log?.LogInformation("Resolved by body: {Tenant} cid={Cid}", body.Tenant, cid);
return byBody;
}
_log?.LogWarning("Resolve FAILED host={Host} header={Header} cid={Cid}",
host, string.IsNullOrWhiteSpace(header) ? "<empty>" : header, cid);
return null;
}
private static string? GetSubdomain(string host)
{
if (Regex.IsMatch(host, @"^\d{1,3}(\.\d{1,3}){3}$")) return null; // IP
var parts = host.Split('.', StringSplitOptions.RemoveEmptyEntries);
return parts.Length >= 3 ? parts[0] : null;
}
}

View File

@@ -0,0 +1,35 @@
using System.Data.Common;
using Microsoft.EntityFrameworkCore.Diagnostics;
namespace AMREZ.EOP.Infrastructures.Tenancy;
public sealed class SearchPathInterceptor : DbConnectionInterceptor
{
private string _schema = "public";
private bool _enabled;
public void Configure(string? schema, bool enabled)
{
_enabled = enabled;
_schema = string.IsNullOrWhiteSpace(schema) ? "public" : schema.Trim();
}
public override async Task ConnectionOpenedAsync(DbConnection connection, ConnectionEndEventData eventData, CancellationToken ct = default)
{
if (_enabled)
{
var schema = string.IsNullOrWhiteSpace(_schema) ? "public" : _schema.Replace("\"", "\"\"");
await using (var cmd = connection.CreateCommand())
{
cmd.CommandText = $"CREATE SCHEMA IF NOT EXISTS \"{schema}\";";
await cmd.ExecuteNonQueryAsync(ct);
}
await using (var cmd = connection.CreateCommand())
{
cmd.CommandText = $"SET LOCAL search_path TO \"{schema}\", pg_catalog;";
await cmd.ExecuteNonQueryAsync(ct);
}
}
await base.ConnectionOpenedAsync(connection, eventData, ct);
}
}

View File

@@ -0,0 +1,14 @@
using AMREZ.EOP.Abstractions.Applications.Tenancy;
using AMREZ.EOP.Domain.Shared.Tenancy;
namespace AMREZ.EOP.Infrastructures.Tenancy;
public class TenantContext : ITenantContext
{
public string TenantKey { get; init; }
public string Id { get; init; } = default!;
public string? Schema { get; init; }
public string? ConnectionString { get; init; }
public TenantMode Mode { get; init; } = TenantMode.Rls;
public bool IsActive { get; init; }
}

View File

@@ -0,0 +1,55 @@
using AMREZ.EOP.Abstractions.Applications.Tenancy;
using AMREZ.EOP.Domain.Shared.Tenancy;
using AMREZ.EOP.Infrastructures.Data;
using AMREZ.EOP.Infrastructures.Options;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace AMREZ.EOP.Infrastructures.Tenancy;
public sealed class TenantDbContextFactory : ITenantDbContextFactory
{
private readonly IOptions<AuthOptions> _opts;
private readonly SearchPathInterceptor _searchPath;
private readonly TenantRlsInterceptor _rls;
public TenantDbContextFactory(IOptions<AuthOptions> opts, SearchPathInterceptor searchPath, TenantRlsInterceptor rls)
{ _opts = opts; _searchPath = searchPath; _rls = rls; }
public TContext Create<TContext>(ITenantContext t) where TContext : class
{
if (typeof(TContext) != typeof(AppDbContext))
throw new NotSupportedException($"Unsupported context: {typeof(TContext).Name}");
var builder = new DbContextOptionsBuilder<AppDbContext>();
// ✅ การ์ด: ถ้า connection string ของ tenant ไม่ใช่รูปแบบ key=value ให้ fallback ไป DefaultConnection (platform)
var raw = (t.ConnectionString ?? string.Empty).Trim();
string conn = IsLikelyValidConnectionString(raw)
? raw
: (_opts.Value.DefaultConnection ?? string.Empty).Trim();
if (!IsLikelyValidConnectionString(conn))
throw new InvalidOperationException(
$"Invalid connection string for tenant '{t.TenantKey}'. Provide a valid per-tenant or DefaultConnection.");
builder.UseNpgsql(conn);
// RLS per-tenant (ถ้า platform ควรกำหนดให้ Interceptor ทำงานในโหมด platform/disable ตามที่คุณนิยาม)
_rls.Configure(t.Id);
builder.AddInterceptors(_rls);
// ใช้ schema ต่อ tenant เมื่อเปิดโหมดนี้
if (_opts.Value.UseSchemaPerTenant)
{
var schema = string.IsNullOrWhiteSpace(t.Schema) ? t.Id : t.Schema!.Trim();
_searchPath.Configure(schema, enabled: true); // CREATE IF NOT EXISTS + SET LOCAL search_path
builder.AddInterceptors(_searchPath);
}
return (new AppDbContext(builder.Options) as TContext)!;
}
private static bool IsLikelyValidConnectionString(string s)
=> !string.IsNullOrWhiteSpace(s) && s.Contains('=');
}

View File

@@ -0,0 +1,57 @@
using System.Collections.Concurrent;
using AMREZ.EOP.Abstractions.Applications.Tenancy;
using AMREZ.EOP.Domain.Shared.Tenancy;
namespace AMREZ.EOP.Infrastructures.Tenancy;
/// <summary>
/// ศูนย์รวม mapping แบบไดนามิก:
/// - Tenants: slug/key -> TenantContext
/// - DomainToTenant: custom-domain -> tenantKey
/// - BaseDomains: ชุดโดเมนแพลตฟอร์มที่รองรับรูปแบบ *.basedomain (รองรับหลายอันได้)
/// คุณสามารถเติม/แก้ไขระหว่างรันงานผ่าน Admin API หรือ job reload จาก DB ได้เลย
/// </summary>
public sealed class TenantMap
{
private readonly ConcurrentDictionary<string, TenantContext> _tenants = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, string> _domainToTenant = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, byte> _baseDomains = new(StringComparer.OrdinalIgnoreCase);
// ====== Query ======
public bool TryGetBySlug(string slug, out ITenantContext ctx)
{
if (_tenants.TryGetValue(slug, out var t))
{
ctx = t; return true;
}
ctx = null!; return false;
}
public bool TryGetByDomain(string domain, out ITenantContext ctx)
{
if (_domainToTenant.TryGetValue(domain, out var key) && _tenants.TryGetValue(key, out var t))
{ ctx = t; return true; }
ctx = null!; return false;
}
public bool TryGetBaseDomainFor(string host, out string? baseDomain)
{
foreach (var kv in _baseDomains.Keys)
{
var bd = kv;
if (host.EndsWith(bd, StringComparison.OrdinalIgnoreCase) && !host.Equals(bd, StringComparison.OrdinalIgnoreCase))
{ baseDomain = bd; return true; }
}
baseDomain = null; return false;
}
// ====== Admin / Reload ======
public void UpsertTenant(string key, TenantContext ctx) => _tenants[key] = ctx;
public bool RemoveTenant(string key) => _tenants.TryRemove(key, out _);
public void MapDomain(string domain, string tenantKey) => _domainToTenant[domain.Trim().ToLowerInvariant()] = tenantKey;
public bool UnmapDomain(string domain) => _domainToTenant.TryRemove(domain.Trim().ToLowerInvariant(), out _);
public void AddBaseDomain(string baseDomain) => _baseDomains[baseDomain.Trim().ToLowerInvariant()] = 1;
public bool RemoveBaseDomain(string baseDomain) => _baseDomains.TryRemove(baseDomain.Trim().ToLowerInvariant(), out _);
}

View File

@@ -0,0 +1,83 @@
using AMREZ.EOP.Domain.Entities.Tenancy;
using AMREZ.EOP.Domain.Shared.Tenancy;
using AMREZ.EOP.Infrastructures.Data;
using AMREZ.EOP.Infrastructures.Options;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
namespace AMREZ.EOP.Infrastructures.Tenancy;
/// <summary>
/// โหลดค่า Tenants / Domains ที่ IsActive=true จาก meta.* ใส่ TenantMap ตอนสตาร์ต
/// และ bootstrap 'public' ถ้ายังไม่มี
/// </summary>
public sealed class TenantMapLoader : IHostedService
{
private readonly IServiceProvider _sp;
public TenantMapLoader(IServiceProvider sp) => _sp = sp;
public async Task StartAsync(CancellationToken ct)
{
using var scope = _sp.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var map = scope.ServiceProvider.GetRequiredService<TenantMap>();
var opt = scope.ServiceProvider.GetRequiredService<IOptions<AuthOptions>>().Value;
// 1) Load tenants (active เท่านั้น)
var tenants = await db.Set<TenantConfig>()
.AsNoTracking()
.Where(x => x.IsActive)
.ToListAsync(ct);
foreach (var t in tenants)
{
map.UpsertTenant(
t.TenantKey,
new TenantContext
{
Id = t.TenantKey,
Schema = string.IsNullOrWhiteSpace(t.Schema) ? t.TenantKey : t.Schema,
ConnectionString = string.IsNullOrWhiteSpace(t.ConnectionString) ? null : t.ConnectionString,
Mode = t.Mode
}
);
}
// 2) Load domains (active เท่านั้น)
var domains = await db.Set<TenantDomain>()
.AsNoTracking()
.Where(d => d.IsActive)
.ToListAsync(ct);
foreach (var d in domains)
{
if (d.IsPlatformBaseDomain)
{
map.AddBaseDomain(d.Domain);
}
else if (!string.IsNullOrWhiteSpace(d.TenantKey))
{
map.MapDomain(d.Domain, d.TenantKey!);
}
}
// 3) Bootstrap 'public' ถ้ายังไม่มี (กัน chicken-and-egg)
if (!map.TryGetBySlug("public", out _))
{
map.UpsertTenant(
"public",
new TenantContext
{
Id = "public",
Schema = "public",
ConnectionString = string.IsNullOrWhiteSpace(opt.DefaultConnection) ? null : opt.DefaultConnection,
Mode = TenantMode.Rls
}
);
}
}
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
}

View File

@@ -0,0 +1,39 @@
using AMREZ.EOP.Abstractions.Applications.Tenancy;
using AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy;
using AMREZ.EOP.Infrastructures.Data;
using AMREZ.EOP.Infrastructures.Options;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace AMREZ.EOP.Infrastructures.Tenancy;
public sealed class TenantProvisioner : ITenantProvisioner
{
private readonly TenantMap _map;
private readonly ITenantDbContextFactory _factory;
private readonly IOptions<AuthOptions> _conn;
public TenantProvisioner(TenantMap map, ITenantDbContextFactory factory, IOptions<AuthOptions> conn)
{ _map = map; _factory = factory; _conn = conn; }
public async Task ProvisionAsync(string tenantKey, CancellationToken ct = default)
{
if (!_map.TryGetBySlug(tenantKey, out var ctx))
{
ctx = new TenantContext
{
Id = tenantKey,
Schema = tenantKey,
ConnectionString = _conn.Value.DefaultConnection,
Mode = AMREZ.EOP.Domain.Shared.Tenancy.TenantMode.Rls
};
_map.UpsertTenant(tenantKey, (TenantContext)ctx);
}
if (_conn.Value.UseSchemaPerTenant)
{
await using var db = _factory.Create<AppDbContext>(ctx);
await db.Database.MigrateAsync(ct); // idempotent
}
}
}

View File

@@ -0,0 +1,27 @@
using System.Data.Common;
using Microsoft.EntityFrameworkCore.Diagnostics;
namespace AMREZ.EOP.Infrastructures.Tenancy;
public sealed class TenantRlsInterceptor : DbConnectionInterceptor
{
private string _tenant = "";
public void Configure(string tenantId) => _tenant = tenantId?.Trim() ?? "";
public override async Task ConnectionOpenedAsync(
DbConnection connection,
ConnectionEndEventData eventData,
CancellationToken cancellationToken = default)
{
// ตั้งแค่ RLS session param เท่านั้น
if (!string.IsNullOrWhiteSpace(_tenant))
{
await using var cmd = connection.CreateCommand();
var v = _tenant.Replace("'", "''");
cmd.CommandText = $"SET LOCAL app.tenant_id = '{v}';";
await cmd.ExecuteNonQueryAsync(cancellationToken);
}
await base.ConnectionOpenedAsync(connection, eventData, cancellationToken);
}
}

View File

@@ -0,0 +1,119 @@
using System.Data;
using System.Text.Json;
using AMREZ.EOP.Abstractions.Applications.Tenancy;
using AMREZ.EOP.Abstractions.Infrastructures.Common;
using AMREZ.EOP.Abstractions.Storage;
using AMREZ.EOP.Domain.Entities.Authentications;
using AMREZ.EOP.Domain.Shared._Users;
using AMREZ.EOP.Infrastructures.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using StackExchange.Redis;
namespace AMREZ.EOP.Infrastructures.UnitOfWork;
public sealed class EFUnitOfWork : IUnitOfWork, IAsyncDisposable
{
private readonly IDbScope _scope;
private readonly ITenantDbContextFactory _factory;
private readonly IConnectionMultiplexer? _redis;
private AppDbContext? _db;
private IDbContextTransaction? _tx;
private ITenantContext? _tenant;
public string Backend => "ef";
public EFUnitOfWork(
IDbScope scope,
ITenantDbContextFactory factory,
IConnectionMultiplexer? redis = null)
{
_scope = scope;
_factory = factory;
_redis = redis; // optional; null ได้
}
public async Task BeginAsync(ITenantContext tenant, IsolationLevel isolation = IsolationLevel.ReadCommitted, CancellationToken ct = default)
{
if (_db is not null) return;
_tenant = tenant ?? throw new ArgumentNullException(nameof(tenant));
_scope.EnsureForTenant(tenant);
_db = _scope.Get<AppDbContext>();
_tx = await _db.Database.BeginTransactionAsync(isolation, ct);
}
public async Task CommitAsync(CancellationToken ct = default)
{
if (_db is null) return; // ยังไม่ Begin
// track entities ที่เปลี่ยน (เพื่อ invalidate cache แบบแม่นขึ้น)
var changedUsers = _db.ChangeTracker.Entries<User>()
.Where(e => e.State is EntityState.Added or EntityState.Modified or EntityState.Deleted)
.Select(e => e.Entity)
.ToList();
var changedIdentities = _db.ChangeTracker.Entries<UserIdentity>()
.Where(e => e.State is EntityState.Added or EntityState.Modified or EntityState.Deleted)
.Select(e => e.Entity)
.ToList();
await _db.SaveChangesAsync(ct);
if (_tx is not null)
await _tx.CommitAsync(ct);
// optional: invalidate/refresh Redis (ล่มก็ไม่พัง UoW)
if (_redis is not null && _tenant is not null && (changedUsers.Count > 0 || changedIdentities.Count > 0))
{
var r = _redis.GetDatabase();
var tenantId = _tenant.Id;
var tasks = new List<Task>();
foreach (var u in changedUsers)
{
var keyId = $"eop:{tenantId}:user:id:{u.Id:N}";
tasks.Add(r.KeyDeleteAsync(keyId));
// refresh cache (ถ้าต้องการ)
var payload = JsonSerializer.Serialize(u);
var ttl = TimeSpan.FromMinutes(5);
tasks.Add(r.StringSetAsync(keyId, payload, ttl));
}
foreach (var i in changedIdentities.Where(i => i.Type == IdentityType.Email))
{
var k = $"eop:{tenantId}:user:identity:email:{i.Identifier}";
tasks.Add(r.KeyDeleteAsync(k));
}
try { await Task.WhenAll(tasks); } catch { /* swallow */ }
}
await DisposeAsync();
}
public async Task RollbackAsync(CancellationToken ct = default)
{
try
{
if (_tx is not null)
await _tx.RollbackAsync(ct);
}
finally
{
await DisposeAsync();
}
}
public ValueTask DisposeAsync()
{
try { _tx?.Dispose(); } catch { /* ignore */ }
try { _db?.Dispose(); } catch { /* ignore */ }
_tx = null; _db = null; _tenant = null;
return ValueTask.CompletedTask;
}
}

View File

@@ -0,0 +1,37 @@
using System.Data;
using AMREZ.EOP.Abstractions.Applications.Tenancy;
using AMREZ.EOP.Abstractions.Infrastructures.Common;
using AMREZ.EOP.Abstractions.Storage;
using StackExchange.Redis;
namespace AMREZ.EOP.Infrastructures.UnitOfWork;
public sealed class RedisUnitOfWork : IUnitOfWork
{
private readonly IDbScope _scope;
private ITransaction? _tx;
public RedisUnitOfWork(IDbScope scope) => _scope = scope;
public string Backend => "redis";
public Task BeginAsync(ITenantContext tenant, IsolationLevel isolation = IsolationLevel.ReadCommitted, CancellationToken ct = default)
{
_scope.EnsureForTenant(tenant);
var db = _scope.Get<IDatabase>();
_tx = db.CreateTransaction();
return Task.CompletedTask;
}
public async Task CommitAsync(CancellationToken ct = default)
{
if (_tx != null) await _tx.ExecuteAsync();
}
public Task RollbackAsync(CancellationToken ct = default)
{
_tx = null; // drop
return Task.CompletedTask;
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}