From 5b77669ab442dfef74612468a757e606283496d7 Mon Sep 17 00:00:00 2001 From: Warren Chen Date: Fri, 10 Jul 2026 23:03:25 +0900 Subject: [PATCH] Refactor member center workflows and UI --- .../Contracts/ProfileRequests.cs | 1 - .../Controllers/UserController.cs | 1 - src/MemberCenter.Api/Program.cs | 1 + .../Abstractions/IProfileService.cs | 2 + .../Abstractions/ITenantManagerService.cs | 12 + .../Abstractions/ITenantService.cs | 2 + .../Constants/AdminPermissions.cs | 12 + .../Models/Admin/TenantManagerDto.cs | 8 + .../Models/Profile/SaveUserAddressRequest.cs | 1 - .../Models/Profile/UserAddressDto.cs | 1 - .../Models/Profile/UserProfileImageDto.cs | 10 + src/MemberCenter.Domain/Entities/Tenant.cs | 1 + .../Entities/TenantManager.cs | 11 + .../Entities/UserAddress.cs | 1 - .../Entities/UserProfileImage.cs | 12 + .../Persistence/MemberCenterDbContext.cs | 38 +- .../20260710090000_RemoveUserAddressUsage.cs | 43 ++ .../20260710100000_AddUserProfileImages.cs | 46 ++ .../20260710110000_AddTenantManagers.cs | 65 +++ .../MemberCenterDbContextModelSnapshot.cs | 102 +++- .../Services/AdminPermissionService.cs | 6 + .../Services/ProfileService.cs | 105 +++- .../Services/TenantManagerService.cs | 219 +++++++++ .../Services/TenantService.cs | 37 ++ .../Controllers/HomeController.cs | 1 - .../Admin/Controllers/AccountsController.cs | 23 + .../Admin/Controllers/TenantsController.cs | 78 ++- .../Admin/Models/AccountDetailViewModel.cs | 4 + .../Admin/Models/TenantDetailViewModel.cs | 15 + .../Areas/Admin/Views/Accounts/Details.cshtml | 22 +- .../Areas/Admin/Views/Tenants/Details.cshtml | 51 ++ .../Controllers/HomeController.cs | 5 +- .../Controllers/ProfileController.cs | 94 +++- .../TenantNewslettersController.cs | 253 ++++++++++ .../Models/Profile/AddressFormViewModel.cs | 3 - .../Profile/ProfileIndexPageViewModel.cs | 4 + .../TenantNewsletterManagementViewModel.cs | 77 +++ src/MemberCenter.Web/Program.cs | 3 +- .../Localization/SharedResource.zh-TW.resx | 23 + .../Views/Profile/Addresses.cshtml | 1 - .../Views/Profile/Index.cshtml | 45 +- .../Views/Shared/_AppSidebar.cshtml | 7 + .../Views/TenantNewsletters/Details.cshtml | 83 ++++ .../Views/TenantNewsletters/Index.cshtml | 69 +++ src/MemberCenter.Web/wwwroot/css/site.css | 462 +++++++++++++++++- .../wwwroot/images/google-g.svg | 6 + .../wwwroot/images/profile-edit-pencil.svg | 3 + src/MemberCenter.Web/wwwroot/js/site.js | 214 ++++++++ 48 files changed, 2219 insertions(+), 64 deletions(-) create mode 100644 src/MemberCenter.Application/Abstractions/ITenantManagerService.cs create mode 100644 src/MemberCenter.Application/Models/Admin/TenantManagerDto.cs create mode 100644 src/MemberCenter.Application/Models/Profile/UserProfileImageDto.cs create mode 100644 src/MemberCenter.Domain/Entities/TenantManager.cs create mode 100644 src/MemberCenter.Domain/Entities/UserProfileImage.cs create mode 100644 src/MemberCenter.Infrastructure/Persistence/Migrations/20260710090000_RemoveUserAddressUsage.cs create mode 100644 src/MemberCenter.Infrastructure/Persistence/Migrations/20260710100000_AddUserProfileImages.cs create mode 100644 src/MemberCenter.Infrastructure/Persistence/Migrations/20260710110000_AddTenantManagers.cs create mode 100644 src/MemberCenter.Infrastructure/Services/TenantManagerService.cs create mode 100644 src/MemberCenter.Web/Controllers/TenantNewslettersController.cs create mode 100644 src/MemberCenter.Web/Models/TenantNewsletters/TenantNewsletterManagementViewModel.cs create mode 100644 src/MemberCenter.Web/Views/TenantNewsletters/Details.cshtml create mode 100644 src/MemberCenter.Web/Views/TenantNewsletters/Index.cshtml create mode 100644 src/MemberCenter.Web/wwwroot/images/google-g.svg create mode 100644 src/MemberCenter.Web/wwwroot/images/profile-edit-pencil.svg diff --git a/src/MemberCenter.Api/Contracts/ProfileRequests.cs b/src/MemberCenter.Api/Contracts/ProfileRequests.cs index 73c062a..8ace0f1 100644 --- a/src/MemberCenter.Api/Contracts/ProfileRequests.cs +++ b/src/MemberCenter.Api/Contracts/ProfileRequests.cs @@ -29,6 +29,5 @@ public sealed record SaveAddressRequest( string AddressLine1, string? AddressLine2, string? CompanyName, - string Usage, bool IsDefault, string? AddressMetaJson); diff --git a/src/MemberCenter.Api/Controllers/UserController.cs b/src/MemberCenter.Api/Controllers/UserController.cs index 708f7b9..18465b4 100644 --- a/src/MemberCenter.Api/Controllers/UserController.cs +++ b/src/MemberCenter.Api/Controllers/UserController.cs @@ -121,7 +121,6 @@ public class UserController : ControllerBase request.AddressLine1, request.AddressLine2, request.CompanyName, - request.Usage, request.IsDefault, request.AddressMetaJson)); return Ok(address); diff --git a/src/MemberCenter.Api/Program.cs b/src/MemberCenter.Api/Program.cs index 3e21729..631fee9 100644 --- a/src/MemberCenter.Api/Program.cs +++ b/src/MemberCenter.Api/Program.cs @@ -259,6 +259,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/src/MemberCenter.Application/Abstractions/IProfileService.cs b/src/MemberCenter.Application/Abstractions/IProfileService.cs index 9dddfbc..634c2f1 100644 --- a/src/MemberCenter.Application/Abstractions/IProfileService.cs +++ b/src/MemberCenter.Application/Abstractions/IProfileService.cs @@ -10,4 +10,6 @@ public interface IProfileService Task GetAddressAsync(Guid userId, Guid addressId); Task SaveAddressAsync(Guid userId, SaveUserAddressRequest request); Task DeleteAddressAsync(Guid userId, Guid addressId); + Task GetProfileImageAsync(Guid userId); + Task SaveProfileImageAsync(Guid userId, byte[] content, string contentType, int width, int height); } diff --git a/src/MemberCenter.Application/Abstractions/ITenantManagerService.cs b/src/MemberCenter.Application/Abstractions/ITenantManagerService.cs new file mode 100644 index 0000000..f5d6d30 --- /dev/null +++ b/src/MemberCenter.Application/Abstractions/ITenantManagerService.cs @@ -0,0 +1,12 @@ +using MemberCenter.Application.Models.Admin; + +namespace MemberCenter.Application.Abstractions; + +public interface ITenantManagerService +{ + Task> ListManagersAsync(Guid tenantId, CancellationToken cancellationToken = default); + Task> ListTenantIdsForUserAsync(Guid userId, CancellationToken cancellationToken = default); + Task CanManageTenantAsync(Guid userId, Guid tenantId, CancellationToken cancellationToken = default); + Task AssignAsync(Guid tenantId, Guid userId, Guid? actorId, CancellationToken cancellationToken = default); + Task RemoveAsync(Guid tenantId, Guid userId, Guid? actorId, CancellationToken cancellationToken = default); +} diff --git a/src/MemberCenter.Application/Abstractions/ITenantService.cs b/src/MemberCenter.Application/Abstractions/ITenantService.cs index f09c55b..8385268 100644 --- a/src/MemberCenter.Application/Abstractions/ITenantService.cs +++ b/src/MemberCenter.Application/Abstractions/ITenantService.cs @@ -5,7 +5,9 @@ namespace MemberCenter.Application.Abstractions; public interface ITenantService { Task> ListAsync(); + Task> ListForUserAsync(Guid userId); Task GetAsync(Guid id); + Task GetForUserAsync(Guid id, Guid userId); Task CreateAsync(string name, List domains, string status, Guid? sendEngineWebhookClientId = null); Task UpdateAsync(Guid id, string name, List domains, string status, Guid? sendEngineWebhookClientId = null); Task SetSendEngineWebhookClientAsync(Guid tenantId, Guid? webhookClientId); diff --git a/src/MemberCenter.Application/Constants/AdminPermissions.cs b/src/MemberCenter.Application/Constants/AdminPermissions.cs index ecb5f16..3efdcb5 100644 --- a/src/MemberCenter.Application/Constants/AdminPermissions.cs +++ b/src/MemberCenter.Application/Constants/AdminPermissions.cs @@ -4,6 +4,7 @@ public static class AdminPermissions { public const string AdminRole = "admin"; public const string SuperuserRole = "superuser"; + public const string TenantManagerRole = "tenant_manager"; public const string Home = "admin.home"; public const string Accounts = "admin.accounts"; @@ -85,6 +86,17 @@ public static class AdminPermissions new(BlacklistIndex, "Blacklist / View", "View email blacklist."), new(BlacklistCreate, "Blacklist / Create", "Add or update email blacklist entries.") ]; + + public static readonly IReadOnlySet TenantManagerPermissions = new HashSet(StringComparer.Ordinal) + { + Home, + Tenants, + TenantsIndex, + NewsletterLists, + NewsletterListsIndex, + Subscriptions, + SubscriptionsIndex + }; } public sealed record AdminPermissionDefinition( diff --git a/src/MemberCenter.Application/Models/Admin/TenantManagerDto.cs b/src/MemberCenter.Application/Models/Admin/TenantManagerDto.cs new file mode 100644 index 0000000..96468a9 --- /dev/null +++ b/src/MemberCenter.Application/Models/Admin/TenantManagerDto.cs @@ -0,0 +1,8 @@ +namespace MemberCenter.Application.Models.Admin; + +public sealed record TenantManagerDto( + Guid TenantId, + Guid UserId, + string Email, + string DisplayName, + DateTimeOffset CreatedAt); diff --git a/src/MemberCenter.Application/Models/Profile/SaveUserAddressRequest.cs b/src/MemberCenter.Application/Models/Profile/SaveUserAddressRequest.cs index 7fb21cb..251e718 100644 --- a/src/MemberCenter.Application/Models/Profile/SaveUserAddressRequest.cs +++ b/src/MemberCenter.Application/Models/Profile/SaveUserAddressRequest.cs @@ -13,6 +13,5 @@ public sealed record SaveUserAddressRequest( string AddressLine1, string? AddressLine2, string? CompanyName, - string Usage, bool IsDefault, string? AddressMetaJson); diff --git a/src/MemberCenter.Application/Models/Profile/UserAddressDto.cs b/src/MemberCenter.Application/Models/Profile/UserAddressDto.cs index 1d90029..8776491 100644 --- a/src/MemberCenter.Application/Models/Profile/UserAddressDto.cs +++ b/src/MemberCenter.Application/Models/Profile/UserAddressDto.cs @@ -14,6 +14,5 @@ public sealed record UserAddressDto( string AddressLine1, string? AddressLine2, string? CompanyName, - string Usage, bool IsDefault, string? AddressMetaJson); diff --git a/src/MemberCenter.Application/Models/Profile/UserProfileImageDto.cs b/src/MemberCenter.Application/Models/Profile/UserProfileImageDto.cs new file mode 100644 index 0000000..847f7db --- /dev/null +++ b/src/MemberCenter.Application/Models/Profile/UserProfileImageDto.cs @@ -0,0 +1,10 @@ +namespace MemberCenter.Application.Models.Profile; + +public sealed record UserProfileImageDto( + Guid UserId, + byte[] Content, + string ContentType, + int Width, + int Height, + long SizeBytes, + DateTimeOffset UpdatedAt); diff --git a/src/MemberCenter.Domain/Entities/Tenant.cs b/src/MemberCenter.Domain/Entities/Tenant.cs index f46a99b..adc745a 100644 --- a/src/MemberCenter.Domain/Entities/Tenant.cs +++ b/src/MemberCenter.Domain/Entities/Tenant.cs @@ -9,4 +9,5 @@ public sealed class Tenant public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public List NewsletterLists { get; set; } = new(); + public List Managers { get; set; } = new(); } diff --git a/src/MemberCenter.Domain/Entities/TenantManager.cs b/src/MemberCenter.Domain/Entities/TenantManager.cs new file mode 100644 index 0000000..b86fad4 --- /dev/null +++ b/src/MemberCenter.Domain/Entities/TenantManager.cs @@ -0,0 +1,11 @@ +namespace MemberCenter.Domain.Entities; + +public sealed class TenantManager +{ + public Guid TenantId { get; set; } + public Guid UserId { get; set; } + public Guid? CreatedBy { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + + public Tenant? Tenant { get; set; } +} diff --git a/src/MemberCenter.Domain/Entities/UserAddress.cs b/src/MemberCenter.Domain/Entities/UserAddress.cs index b16d8bd..45f04f4 100644 --- a/src/MemberCenter.Domain/Entities/UserAddress.cs +++ b/src/MemberCenter.Domain/Entities/UserAddress.cs @@ -17,7 +17,6 @@ public sealed class UserAddress public string AddressLine1 { get; set; } = string.Empty; public string? AddressLine2 { get; set; } public string? CompanyName { get; set; } - public string Usage { get; set; } = "shipping"; public bool IsDefault { get; set; } public JsonDocument? AddressMetaJson { get; set; } public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; diff --git a/src/MemberCenter.Domain/Entities/UserProfileImage.cs b/src/MemberCenter.Domain/Entities/UserProfileImage.cs new file mode 100644 index 0000000..fbfb048 --- /dev/null +++ b/src/MemberCenter.Domain/Entities/UserProfileImage.cs @@ -0,0 +1,12 @@ +namespace MemberCenter.Domain.Entities; + +public sealed class UserProfileImage +{ + public Guid UserId { get; set; } + public byte[] Content { get; set; } = Array.Empty(); + public string ContentType { get; set; } = "image/webp"; + public int Width { get; set; } + public int Height { get; set; } + public long SizeBytes { get; set; } + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/src/MemberCenter.Infrastructure/Persistence/MemberCenterDbContext.cs b/src/MemberCenter.Infrastructure/Persistence/MemberCenterDbContext.cs index 679c988..fc54c56 100644 --- a/src/MemberCenter.Infrastructure/Persistence/MemberCenterDbContext.cs +++ b/src/MemberCenter.Infrastructure/Persistence/MemberCenterDbContext.cs @@ -15,9 +15,11 @@ public class MemberCenterDbContext } public DbSet Tenants => Set(); + public DbSet TenantManagers => Set(); public DbSet NewsletterLists => Set(); public DbSet NewsletterSubscriptions => Set(); public DbSet UserProfiles => Set(); + public DbSet UserProfileImages => Set(); public DbSet UserAddresses => Set(); public DbSet EmailBlacklist => Set(); public DbSet EmailVerifications => Set(); @@ -48,6 +50,26 @@ public class MemberCenterDbContext entity.Property(x => x.CreatedAt).HasDefaultValueSql("now()"); }); + builder.Entity(entity => + { + entity.ToTable("tenant_managers"); + entity.HasKey(x => new { x.TenantId, x.UserId }); + entity.Property(x => x.CreatedAt).HasDefaultValueSql("now()"); + entity.HasIndex(x => x.UserId).HasDatabaseName("idx_tenant_managers_user_id"); + entity.HasOne(x => x.Tenant) + .WithMany(x => x.Managers) + .HasForeignKey(x => x.TenantId) + .OnDelete(DeleteBehavior.Cascade); + entity.HasOne() + .WithMany() + .HasForeignKey(x => x.UserId) + .OnDelete(DeleteBehavior.Cascade); + entity.HasOne() + .WithMany() + .HasForeignKey(x => x.CreatedBy) + .OnDelete(DeleteBehavior.SetNull); + }); + builder.Entity(entity => { entity.ToTable("newsletter_lists"); @@ -110,6 +132,19 @@ public class MemberCenterDbContext .OnDelete(DeleteBehavior.Cascade); }); + builder.Entity(entity => + { + entity.ToTable("user_profile_images"); + entity.HasKey(x => x.UserId); + entity.Property(x => x.Content).IsRequired().HasColumnType("bytea"); + entity.Property(x => x.ContentType).IsRequired().HasMaxLength(100); + entity.Property(x => x.UpdatedAt).HasDefaultValueSql("now()"); + entity.HasOne() + .WithOne() + .HasForeignKey(x => x.UserId) + .OnDelete(DeleteBehavior.Cascade); + }); + builder.Entity(entity => { entity.ToTable("user_addresses"); @@ -125,7 +160,6 @@ public class MemberCenterDbContext entity.Property(x => x.AddressLine1).IsRequired().HasMaxLength(255); entity.Property(x => x.AddressLine2).HasMaxLength(255); entity.Property(x => x.CompanyName).HasMaxLength(200); - entity.Property(x => x.Usage).IsRequired().HasMaxLength(20).HasDefaultValue("shipping"); entity.Property(x => x.IsDefault).HasDefaultValue(false); entity.Property(x => x.CreatedAt).HasDefaultValueSql("now()"); entity.Property(x => x.UpdatedAt).HasDefaultValueSql("now()"); @@ -135,8 +169,6 @@ public class MemberCenterDbContext v => v == null ? null : v.RootElement.GetRawText(), v => string.IsNullOrWhiteSpace(v) ? null : System.Text.Json.JsonDocument.Parse(v, new System.Text.Json.JsonDocumentOptions())); entity.HasIndex(x => x.UserId).HasDatabaseName("idx_user_addresses_user_id"); - entity.HasIndex(x => new { x.UserId, x.Usage }) - .HasDatabaseName("idx_user_addresses_user_id_usage"); entity.HasIndex(x => new { x.UserId, x.IsDefault }) .IsUnique() .HasFilter("\"IsDefault\" = true") diff --git a/src/MemberCenter.Infrastructure/Persistence/Migrations/20260710090000_RemoveUserAddressUsage.cs b/src/MemberCenter.Infrastructure/Persistence/Migrations/20260710090000_RemoveUserAddressUsage.cs new file mode 100644 index 0000000..63743bb --- /dev/null +++ b/src/MemberCenter.Infrastructure/Persistence/Migrations/20260710090000_RemoveUserAddressUsage.cs @@ -0,0 +1,43 @@ +using MemberCenter.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MemberCenter.Infrastructure.Persistence.Migrations +{ + /// + [DbContext(typeof(MemberCenterDbContext))] + [Migration("20260710090000_RemoveUserAddressUsage")] + public partial class RemoveUserAddressUsage : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "idx_user_addresses_user_id_usage", + table: "user_addresses"); + + migrationBuilder.DropColumn( + name: "Usage", + table: "user_addresses"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Usage", + table: "user_addresses", + type: "character varying(20)", + maxLength: 20, + nullable: false, + defaultValue: "shipping"); + + migrationBuilder.CreateIndex( + name: "idx_user_addresses_user_id_usage", + table: "user_addresses", + columns: new[] { "UserId", "Usage" }); + } + } +} diff --git a/src/MemberCenter.Infrastructure/Persistence/Migrations/20260710100000_AddUserProfileImages.cs b/src/MemberCenter.Infrastructure/Persistence/Migrations/20260710100000_AddUserProfileImages.cs new file mode 100644 index 0000000..004fb22 --- /dev/null +++ b/src/MemberCenter.Infrastructure/Persistence/Migrations/20260710100000_AddUserProfileImages.cs @@ -0,0 +1,46 @@ +using System; +using MemberCenter.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MemberCenter.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(MemberCenterDbContext))] + [Migration("20260710100000_AddUserProfileImages")] + public partial class AddUserProfileImages : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "user_profile_images", + columns: table => new + { + UserId = table.Column(type: "uuid", nullable: false), + Content = table.Column(type: "bytea", nullable: false), + ContentType = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Width = table.Column(type: "integer", nullable: false), + Height = table.Column(type: "integer", nullable: false), + SizeBytes = table.Column(type: "bigint", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()") + }, + constraints: table => + { + table.PrimaryKey("PK_user_profile_images", x => x.UserId); + table.ForeignKey( + name: "FK_user_profile_images_users_UserId", + column: x => x.UserId, + principalTable: "users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "user_profile_images"); + } + } +} diff --git a/src/MemberCenter.Infrastructure/Persistence/Migrations/20260710110000_AddTenantManagers.cs b/src/MemberCenter.Infrastructure/Persistence/Migrations/20260710110000_AddTenantManagers.cs new file mode 100644 index 0000000..0e8ab66 --- /dev/null +++ b/src/MemberCenter.Infrastructure/Persistence/Migrations/20260710110000_AddTenantManagers.cs @@ -0,0 +1,65 @@ +using System; +using MemberCenter.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MemberCenter.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(MemberCenterDbContext))] + [Migration("20260710110000_AddTenantManagers")] + public partial class AddTenantManagers : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "tenant_managers", + columns: table => new + { + TenantId = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + CreatedBy = table.Column(type: "uuid", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()") + }, + constraints: table => + { + table.PrimaryKey("PK_tenant_managers", x => new { x.TenantId, x.UserId }); + table.ForeignKey( + name: "FK_tenant_managers_tenants_TenantId", + column: x => x.TenantId, + principalTable: "tenants", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_tenant_managers_users_CreatedBy", + column: x => x.CreatedBy, + principalTable: "users", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_tenant_managers_users_UserId", + column: x => x.UserId, + principalTable: "users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_tenant_managers_CreatedBy", + table: "tenant_managers", + column: "CreatedBy"); + + migrationBuilder.CreateIndex( + name: "idx_tenant_managers_user_id", + table: "tenant_managers", + column: "UserId"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "tenant_managers"); + } + } +} diff --git a/src/MemberCenter.Infrastructure/Persistence/Migrations/MemberCenterDbContextModelSnapshot.cs b/src/MemberCenter.Infrastructure/Persistence/Migrations/MemberCenterDbContextModelSnapshot.cs index d0cfc64..5a757f8 100644 --- a/src/MemberCenter.Infrastructure/Persistence/Migrations/MemberCenterDbContextModelSnapshot.cs +++ b/src/MemberCenter.Infrastructure/Persistence/Migrations/MemberCenterDbContextModelSnapshot.cs @@ -521,6 +521,32 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations b.ToTable("tenants", (string)null); }); + modelBuilder.Entity("MemberCenter.Domain.Entities.TenantManager", b => + { + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.HasKey("TenantId", "UserId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("UserId") + .HasDatabaseName("idx_tenant_managers_user_id"); + + b.ToTable("tenant_managers", (string)null); + }); + modelBuilder.Entity("MemberCenter.Domain.Entities.UnsubscribeToken", b => { b.Property("Id") @@ -620,13 +646,6 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations .HasColumnType("timestamp with time zone") .HasDefaultValueSql("now()"); - b.Property("Usage") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(20) - .HasColumnType("character varying(20)") - .HasDefaultValue("shipping"); - b.Property("UserId") .HasColumnType("uuid"); @@ -640,9 +659,6 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations .HasDatabaseName("ux_user_addresses_default_per_user") .HasFilter("\"IsDefault\" = true"); - b.HasIndex("UserId", "Usage") - .HasDatabaseName("idx_user_addresses_user_id_usage"); - b.ToTable("user_addresses", (string)null); }); @@ -721,6 +737,39 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations b.ToTable("user_profiles", (string)null); }); + modelBuilder.Entity("MemberCenter.Domain.Entities.UserProfileImage", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("UserId"); + + b.ToTable("user_profile_images", (string)null); + }); + modelBuilder.Entity("MemberCenter.Infrastructure.Identity.ApplicationRole", b => { b.Property("Id") @@ -1255,6 +1304,28 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations b.Navigation("List"); }); + modelBuilder.Entity("MemberCenter.Domain.Entities.TenantManager", b => + { + b.HasOne("MemberCenter.Domain.Entities.Tenant", "Tenant") + .WithMany("Managers") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + modelBuilder.Entity("MemberCenter.Domain.Entities.UnsubscribeToken", b => { b.HasOne("MemberCenter.Domain.Entities.NewsletterSubscription", "Subscription") @@ -1284,6 +1355,15 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations .IsRequired(); }); + modelBuilder.Entity("MemberCenter.Domain.Entities.UserProfileImage", b => + { + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null) + .WithOne() + .HasForeignKey("MemberCenter.Domain.Entities.UserProfileImage", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationRole", null) @@ -1376,6 +1456,8 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations modelBuilder.Entity("MemberCenter.Domain.Entities.Tenant", b => { + b.Navigation("Managers"); + b.Navigation("NewsletterLists"); }); diff --git a/src/MemberCenter.Infrastructure/Services/AdminPermissionService.cs b/src/MemberCenter.Infrastructure/Services/AdminPermissionService.cs index eba5e5d..f7cbf5b 100644 --- a/src/MemberCenter.Infrastructure/Services/AdminPermissionService.cs +++ b/src/MemberCenter.Infrastructure/Services/AdminPermissionService.cs @@ -36,6 +36,11 @@ public sealed class AdminPermissionService : IAdminPermissionChecker, IAdminPerm return true; } + if (user.IsInRole(AdminPermissions.TenantManagerRole)) + { + return AdminPermissions.TenantManagerPermissions.Contains(permissionKey); + } + var roleNames = user.FindAll(ClaimTypes.Role) .Select(claim => claim.Value) .Where(value => !string.IsNullOrWhiteSpace(value)) @@ -99,6 +104,7 @@ public sealed class AdminPermissionService : IAdminPermissionChecker, IAdminPerm await EnsureRoleExistsAsync(AdminPermissions.AdminRole); await EnsureRoleExistsAsync(AdminPermissions.SuperuserRole); + await EnsureRoleExistsAsync(AdminPermissions.TenantManagerRole); await _dbContext.SaveChangesAsync(cancellationToken); diff --git a/src/MemberCenter.Infrastructure/Services/ProfileService.cs b/src/MemberCenter.Infrastructure/Services/ProfileService.cs index c24512c..06bb8d7 100644 --- a/src/MemberCenter.Infrastructure/Services/ProfileService.cs +++ b/src/MemberCenter.Infrastructure/Services/ProfileService.cs @@ -11,6 +11,15 @@ namespace MemberCenter.Infrastructure.Services; public sealed class ProfileService : IProfileService { + private const int MaxProfileImageSizeBytes = 512 * 1024; + + private static readonly HashSet AllowedProfileImageContentTypes = new(StringComparer.OrdinalIgnoreCase) + { + "image/webp", + "image/png", + "image/jpeg" + }; + private static readonly HashSet AllowedGenders = new(StringComparer.OrdinalIgnoreCase) { "male", @@ -19,13 +28,6 @@ public sealed class ProfileService : IProfileService "unspecified" }; - private static readonly HashSet AllowedAddressUsages = new(StringComparer.OrdinalIgnoreCase) - { - "shipping", - "billing", - "both" - }; - private readonly MemberCenterDbContext _dbContext; private readonly IAuditLogWriter _auditLogWriter; private readonly UserManager _userManager; @@ -109,7 +111,6 @@ public sealed class ProfileService : IProfileService { await using var transaction = await _dbContext.Database.BeginTransactionAsync(); - var usage = NormalizeUsage(request.Usage); var address = request.Id.HasValue ? await _dbContext.UserAddresses.FirstOrDefaultAsync(x => x.UserId == userId && x.Id == request.Id.Value) : null; @@ -142,7 +143,6 @@ public sealed class ProfileService : IProfileService address.AddressLine1 = RequireValue(request.AddressLine1, "AddressLine1", 255); address.AddressLine2 = CleanValue(request.AddressLine2, 255); address.CompanyName = CleanValue(request.CompanyName, 200); - address.Usage = usage; address.AddressMetaJson = ParseOptionalJson(request.AddressMetaJson); address.UpdatedAt = DateTimeOffset.UtcNow; @@ -178,7 +178,6 @@ public sealed class ProfileService : IProfileService { user_id = userId, address_id = address.Id, - address.Usage, address.IsDefault }); @@ -243,6 +242,70 @@ public sealed class ProfileService : IProfileService await transaction.CommitAsync(); } + public async Task GetProfileImageAsync(Guid userId) + { + var image = await _dbContext.UserProfileImages + .AsNoTracking() + .FirstOrDefaultAsync(x => x.UserId == userId); + + return image is null ? null : MapProfileImage(image); + } + + public async Task SaveProfileImageAsync(Guid userId, byte[] content, string contentType, int width, int height) + { + if (content.Length == 0) + { + throw new InvalidOperationException("Profile image is required."); + } + + if (content.Length > MaxProfileImageSizeBytes) + { + throw new InvalidOperationException("Profile image exceeds the size limit."); + } + + if (!AllowedProfileImageContentTypes.Contains(contentType)) + { + throw new InvalidOperationException("Profile image type is not supported."); + } + + if (width <= 0 || height <= 0) + { + throw new InvalidOperationException("Profile image dimensions are invalid."); + } + + var user = await _userManager.FindByIdAsync(userId.ToString()); + if (user is null) + { + throw new InvalidOperationException("User not found."); + } + + var image = await _dbContext.UserProfileImages.FirstOrDefaultAsync(x => x.UserId == userId); + if (image is null) + { + image = new UserProfileImage { UserId = userId }; + _dbContext.UserProfileImages.Add(image); + } + + image.Content = content; + image.ContentType = contentType; + image.Width = width; + image.Height = height; + image.SizeBytes = content.Length; + image.UpdatedAt = DateTimeOffset.UtcNow; + + await _auditLogWriter.WriteAsync("user", userId, "profile.image_updated", new + { + user_id = userId, + email = user.Email, + content_type = image.ContentType, + size_bytes = image.SizeBytes, + width = image.Width, + height = image.Height + }); + + await _dbContext.SaveChangesAsync(); + } + private async Task EnsureProfileAsync(Guid userId) { var profile = await _dbContext.UserProfiles.FirstOrDefaultAsync(x => x.UserId == userId); @@ -297,10 +360,19 @@ public sealed class ProfileService : IProfileService address.AddressLine1, address.AddressLine2, address.CompanyName, - address.Usage, address.IsDefault, address.AddressMetaJson?.RootElement.GetRawText()); + private static UserProfileImageDto MapProfileImage(UserProfileImage image) => + new( + image.UserId, + image.Content, + image.ContentType, + image.Width, + image.Height, + image.SizeBytes, + image.UpdatedAt); + private static string RequireValue(string? value, string fieldName, int maxLength) { var cleaned = CleanValue(value, maxLength); @@ -339,17 +411,6 @@ public sealed class ProfileService : IProfileService return normalized; } - private static string NormalizeUsage(string? value) - { - var normalized = RequireValue(value, "Usage", 20).ToLowerInvariant(); - if (!AllowedAddressUsages.Contains(normalized)) - { - throw new InvalidOperationException("Invalid address usage."); - } - - return normalized; - } - private static string RequireCountryCode(string? value) { var normalized = RequireValue(value, "CountryCode", 2).ToUpperInvariant(); diff --git a/src/MemberCenter.Infrastructure/Services/TenantManagerService.cs b/src/MemberCenter.Infrastructure/Services/TenantManagerService.cs new file mode 100644 index 0000000..24ccda0 --- /dev/null +++ b/src/MemberCenter.Infrastructure/Services/TenantManagerService.cs @@ -0,0 +1,219 @@ +using MemberCenter.Application.Abstractions; +using MemberCenter.Application.Constants; +using MemberCenter.Application.Models.Admin; +using MemberCenter.Domain.Entities; +using MemberCenter.Infrastructure.Identity; +using MemberCenter.Infrastructure.Persistence; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; + +namespace MemberCenter.Infrastructure.Services; + +public sealed class TenantManagerService : ITenantManagerService +{ + private readonly MemberCenterDbContext _dbContext; + private readonly UserManager _userManager; + private readonly RoleManager _roleManager; + private readonly IAuditLogWriter _auditLogWriter; + + public TenantManagerService( + MemberCenterDbContext dbContext, + UserManager userManager, + RoleManager roleManager, + IAuditLogWriter auditLogWriter) + { + _dbContext = dbContext; + _userManager = userManager; + _roleManager = roleManager; + _auditLogWriter = auditLogWriter; + } + + public async Task> ListManagersAsync( + Guid tenantId, + CancellationToken cancellationToken = default) + { + var managers = await _dbContext.TenantManagers + .AsNoTracking() + .Where(manager => manager.TenantId == tenantId) + .Join( + _dbContext.Users.AsNoTracking(), + manager => manager.UserId, + user => user.Id, + (manager, user) => new + { + manager.TenantId, + manager.UserId, + user.Email, + CreatedAt = manager.CreatedAt + }) + .GroupJoin( + _dbContext.UserProfiles.AsNoTracking(), + manager => manager.UserId, + profile => profile.UserId, + (manager, profiles) => new { manager, profiles }) + .SelectMany( + item => item.profiles.DefaultIfEmpty(), + (item, profile) => new + { + item.manager.TenantId, + item.manager.UserId, + Email = item.manager.Email ?? string.Empty, + FirstName = profile == null ? string.Empty : profile.FirstName, + LastName = profile == null ? string.Empty : profile.LastName, + item.manager.CreatedAt + }) + .OrderBy(manager => manager.Email) + .ToListAsync(cancellationToken); + + return managers + .Select(manager => + { + var displayName = string.Join(" ", new[] { manager.FirstName, manager.LastName } + .Where(part => !string.IsNullOrWhiteSpace(part))); + if (string.IsNullOrWhiteSpace(displayName)) + { + displayName = manager.Email; + } + + return new TenantManagerDto( + manager.TenantId, + manager.UserId, + manager.Email, + displayName, + manager.CreatedAt); + }) + .ToList(); + } + + public async Task> ListTenantIdsForUserAsync( + Guid userId, + CancellationToken cancellationToken = default) + { + var tenantIds = await _dbContext.TenantManagers + .AsNoTracking() + .Where(manager => manager.UserId == userId) + .Select(manager => manager.TenantId) + .Distinct() + .ToListAsync(cancellationToken); + + return tenantIds.ToHashSet(); + } + + public Task CanManageTenantAsync(Guid userId, Guid tenantId, CancellationToken cancellationToken = default) + { + return _dbContext.TenantManagers + .AsNoTracking() + .AnyAsync(manager => manager.UserId == userId && manager.TenantId == tenantId, cancellationToken); + } + + public async Task AssignAsync( + Guid tenantId, + Guid userId, + Guid? actorId, + CancellationToken cancellationToken = default) + { + var tenantExists = await _dbContext.Tenants + .AsNoTracking() + .AnyAsync(tenant => tenant.Id == tenantId, cancellationToken); + var user = await _userManager.FindByIdAsync(userId.ToString()); + if (!tenantExists || user is null || user.DisabledAt.HasValue) + { + return false; + } + + var exists = await _dbContext.TenantManagers + .AnyAsync(manager => manager.TenantId == tenantId && manager.UserId == userId, cancellationToken); + if (exists) + { + await EnsureTenantManagerRoleAsync(user); + return true; + } + + await using var transaction = await _dbContext.Database.BeginTransactionAsync(cancellationToken); + await EnsureTenantManagerRoleAsync(user); + _dbContext.TenantManagers.Add(new TenantManager + { + TenantId = tenantId, + UserId = userId, + CreatedBy = actorId, + CreatedAt = DateTimeOffset.UtcNow + }); + await _dbContext.SaveChangesAsync(cancellationToken); + + await _auditLogWriter.WriteAsync("user", actorId, "tenant_manager.assigned", new + { + tenant_id = tenantId, + user_id = userId, + email = user.Email + }); + await transaction.CommitAsync(cancellationToken); + return true; + } + + public async Task RemoveAsync( + Guid tenantId, + Guid userId, + Guid? actorId, + CancellationToken cancellationToken = default) + { + var manager = await _dbContext.TenantManagers + .FirstOrDefaultAsync(item => item.TenantId == tenantId && item.UserId == userId, cancellationToken); + if (manager is null) + { + return false; + } + + await using var transaction = await _dbContext.Database.BeginTransactionAsync(cancellationToken); + _dbContext.TenantManagers.Remove(manager); + await _dbContext.SaveChangesAsync(cancellationToken); + + var user = await _userManager.FindByIdAsync(userId.ToString()); + if (user is not null) + { + var hasAnyTenant = await _dbContext.TenantManagers + .AsNoTracking() + .AnyAsync(item => item.UserId == userId, cancellationToken); + if (!hasAnyTenant && await _userManager.IsInRoleAsync(user, AdminPermissions.TenantManagerRole)) + { + EnsureSucceeded(await _userManager.RemoveFromRoleAsync(user, AdminPermissions.TenantManagerRole)); + } + } + + await _auditLogWriter.WriteAsync("user", actorId, "tenant_manager.removed", new + { + tenant_id = tenantId, + user_id = userId, + email = user?.Email + }); + await transaction.CommitAsync(cancellationToken); + return true; + } + + private async Task EnsureTenantManagerRoleAsync(ApplicationUser user) + { + if (!await _roleManager.RoleExistsAsync(AdminPermissions.TenantManagerRole)) + { + EnsureSucceeded(await _roleManager.CreateAsync(new ApplicationRole + { + Id = Guid.NewGuid(), + Name = AdminPermissions.TenantManagerRole, + NormalizedName = AdminPermissions.TenantManagerRole.ToUpperInvariant() + })); + } + + if (!await _userManager.IsInRoleAsync(user, AdminPermissions.TenantManagerRole)) + { + EnsureSucceeded(await _userManager.AddToRoleAsync(user, AdminPermissions.TenantManagerRole)); + } + } + + private static void EnsureSucceeded(IdentityResult result) + { + if (result.Succeeded) + { + return; + } + + throw new InvalidOperationException(string.Join("; ", result.Errors.Select(error => error.Description))); + } +} diff --git a/src/MemberCenter.Infrastructure/Services/TenantService.cs b/src/MemberCenter.Infrastructure/Services/TenantService.cs index 44ef4bb..1d31292 100644 --- a/src/MemberCenter.Infrastructure/Services/TenantService.cs +++ b/src/MemberCenter.Infrastructure/Services/TenantService.cs @@ -24,6 +24,23 @@ public sealed class TenantService : ITenantService return tenants.Select(t => MapTenant(t, mappings.TryGetValue(t.Id, out var clientId) ? clientId : null)).ToList(); } + public async Task> ListForUserAsync(Guid userId) + { + var tenants = await _dbContext.TenantManagers + .AsNoTracking() + .Where(manager => manager.UserId == userId) + .Join( + _dbContext.Tenants.AsNoTracking(), + manager => manager.TenantId, + tenant => tenant.Id, + (_, tenant) => tenant) + .OrderBy(tenant => tenant.Name) + .ToListAsync(); + + var mappings = await LoadWebhookClientMappingsAsync(tenants.Select(t => t.Id)); + return tenants.Select(t => MapTenant(t, mappings.TryGetValue(t.Id, out var clientId) ? clientId : null)).ToList(); + } + public async Task GetAsync(Guid id) { var tenant = await _dbContext.Tenants.FindAsync(id); @@ -36,6 +53,26 @@ public sealed class TenantService : ITenantService return MapTenant(tenant, mapping); } + public async Task GetForUserAsync(Guid id, Guid userId) + { + var tenant = await _dbContext.TenantManagers + .AsNoTracking() + .Where(manager => manager.UserId == userId && manager.TenantId == id) + .Join( + _dbContext.Tenants.AsNoTracking(), + manager => manager.TenantId, + tenant => tenant.Id, + (_, tenant) => tenant) + .FirstOrDefaultAsync(); + if (tenant is null) + { + return null; + } + + var mapping = await LoadWebhookClientMappingAsync(id); + return MapTenant(tenant, mapping); + } + public async Task CreateAsync(string name, List domains, string status, Guid? sendEngineWebhookClientId = null) { var tenant = new Tenant diff --git a/src/MemberCenter.TestSite/Controllers/HomeController.cs b/src/MemberCenter.TestSite/Controllers/HomeController.cs index d0b4784..2edfb1d 100644 --- a/src/MemberCenter.TestSite/Controllers/HomeController.cs +++ b/src/MemberCenter.TestSite/Controllers/HomeController.cs @@ -206,7 +206,6 @@ public class HomeController : Controller AddressLine1 = "No. 1, Test Road", AddressLine2 = "Test Building", CompanyName = "Test Company", - Usage = "shipping", IsDefault = true, AddressMetaJson = "{\"source\":\"MemberCenter.TestSite\"}" }; diff --git a/src/MemberCenter.Web/Areas/Admin/Controllers/AccountsController.cs b/src/MemberCenter.Web/Areas/Admin/Controllers/AccountsController.cs index c858b15..ed85600 100644 --- a/src/MemberCenter.Web/Areas/Admin/Controllers/AccountsController.cs +++ b/src/MemberCenter.Web/Areas/Admin/Controllers/AccountsController.cs @@ -109,6 +109,7 @@ public class AccountsController : Controller var isSuperuser = await _userManager.IsInRoleAsync(user, AdminPermissions.SuperuserRole); var operatorIsSuperuser = User.IsInRole(AdminPermissions.SuperuserRole); var isBlacklisted = await _emailBlacklistService.IsBlacklistedAsync(user.Email ?? string.Empty); + var profileImage = await _profileService.GetProfileImageAsync(id); var mappedProfile = MapProfile(profile, user.EmailConfirmed); var displayName = string.Join(" ", new[] { mappedProfile.LastName, mappedProfile.FirstName } .Where(value => !string.IsNullOrWhiteSpace(value))); @@ -129,6 +130,8 @@ public class AccountsController : Controller IsBlacklisted = isBlacklisted, IsAdmin = isAdmin, IsSuperuser = isSuperuser, + HasProfileImage = profileImage is not null, + ProfileImageVersion = profileImage?.UpdatedAt.ToUnixTimeSeconds() ?? 0, CreatedAt = user.CreatedAt, LastLoginAt = user.LastLoginAt, Profile = mappedProfile, @@ -140,6 +143,26 @@ public class AccountsController : Controller }); } + [HttpGet("{id:guid}/avatar")] + [AdminPermission(AdminPermissions.AccountsIndex)] + public async Task Avatar(Guid id) + { + var user = await _userManager.FindByIdAsync(id.ToString()); + if (user is null) + { + return NotFound(); + } + + var image = await _profileService.GetProfileImageAsync(id); + if (image is null) + { + return NotFound(); + } + + Response.Headers.CacheControl = "private, max-age=300"; + return File(image.Content, image.ContentType); + } + [HttpPost("{id:guid}/admin")] [Authorize(Policy = "Superuser")] [AdminPermission(AdminPermissions.AccountsSetAdmin)] diff --git a/src/MemberCenter.Web/Areas/Admin/Controllers/TenantsController.cs b/src/MemberCenter.Web/Areas/Admin/Controllers/TenantsController.cs index ee72bec..644e82b 100644 --- a/src/MemberCenter.Web/Areas/Admin/Controllers/TenantsController.cs +++ b/src/MemberCenter.Web/Areas/Admin/Controllers/TenantsController.cs @@ -2,10 +2,12 @@ using MemberCenter.Application.Abstractions; using MemberCenter.Application.Constants; using MemberCenter.Domain.Constants; using MemberCenter.Infrastructure.Persistence; +using MemberCenter.Infrastructure.Identity; using MemberCenter.Web.Authorization; using MemberCenter.Web.Areas.Admin.Models; using MemberCenter.Web.Models.Admin; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -18,12 +20,20 @@ namespace MemberCenter.Web.Areas.Admin.Controllers; public class TenantsController : Controller { private readonly ITenantService _tenantService; + private readonly ITenantManagerService _tenantManagerService; private readonly MemberCenterDbContext _dbContext; + private readonly UserManager _userManager; - public TenantsController(ITenantService tenantService, MemberCenterDbContext dbContext) + public TenantsController( + ITenantService tenantService, + ITenantManagerService tenantManagerService, + MemberCenterDbContext dbContext, + UserManager userManager) { _tenantService = tenantService; + _tenantManagerService = tenantManagerService; _dbContext = dbContext; + _userManager = userManager; } [HttpGet("")] @@ -55,14 +65,67 @@ public class TenantsController : Controller }) .OrderBy(list => list.Name) .ToListAsync(); + var managers = await _tenantManagerService.ListManagersAsync(id); return View(new TenantDetailViewModel { Tenant = tenant, - NewsletterLists = newsletters + NewsletterLists = newsletters, + Managers = managers.Select(manager => new TenantManagerItemViewModel + { + UserId = manager.UserId, + Email = manager.Email, + DisplayName = manager.DisplayName, + CreatedAt = manager.CreatedAt + }).ToList(), + CanManageManagers = User.IsInRole(AdminPermissions.AdminRole) || User.IsInRole(AdminPermissions.SuperuserRole) }); } + [HttpPost("{id:guid}/managers")] + [AdminPermission(AdminPermissions.TenantsEdit)] + [ValidateAntiForgeryToken] + public async Task AssignManager(Guid id, string email) + { + if (string.IsNullOrWhiteSpace(email)) + { + TempData["Error"] = "Manager email is required."; + return RedirectToAction("Details", new { id }); + } + + var tenant = await _tenantService.GetAsync(id); + if (tenant is null) + { + return NotFound(); + } + + var user = await _userManager.FindByEmailAsync(email.Trim()); + if (user is null) + { + TempData["Error"] = "No member was found with that email."; + return RedirectToAction("Details", new { id }); + } + + var actorId = GetCurrentUserId(); + var assigned = await _tenantManagerService.AssignAsync(id, user.Id, actorId); + TempData[assigned ? "Result" : "Error"] = assigned + ? "Tenant manager assigned." + : "Unable to assign tenant manager."; + return RedirectToAction("Details", new { id }); + } + + [HttpPost("{id:guid}/managers/{userId:guid}/delete")] + [AdminPermission(AdminPermissions.TenantsEdit)] + [ValidateAntiForgeryToken] + public async Task RemoveManager(Guid id, Guid userId) + { + var removed = await _tenantManagerService.RemoveAsync(id, userId, GetCurrentUserId()); + TempData[removed ? "Result" : "Error"] = removed + ? "Tenant manager removed." + : "Unable to remove tenant manager."; + return RedirectToAction("Details", new { id }); + } + [HttpGet("create")] [AdminPermission(AdminPermissions.TenantsCreate)] public IActionResult Create() @@ -159,4 +222,15 @@ public class TenantsController : Controller webhookClientId = parsed; return true; } + + private Guid? GetCurrentUserId() + { + var userId = _userManager.GetUserId(User); + if (!Guid.TryParse(userId, out var parsed)) + { + return null; + } + + return parsed; + } } diff --git a/src/MemberCenter.Web/Areas/Admin/Models/AccountDetailViewModel.cs b/src/MemberCenter.Web/Areas/Admin/Models/AccountDetailViewModel.cs index f578543..bd1b20e 100644 --- a/src/MemberCenter.Web/Areas/Admin/Models/AccountDetailViewModel.cs +++ b/src/MemberCenter.Web/Areas/Admin/Models/AccountDetailViewModel.cs @@ -17,6 +17,10 @@ public sealed class AccountDetailViewModel public bool IsSuperuser { get; set; } + public bool HasProfileImage { get; set; } + + public long ProfileImageVersion { get; set; } + public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset? LastLoginAt { get; set; } diff --git a/src/MemberCenter.Web/Areas/Admin/Models/TenantDetailViewModel.cs b/src/MemberCenter.Web/Areas/Admin/Models/TenantDetailViewModel.cs index 21bd66b..21058af 100644 --- a/src/MemberCenter.Web/Areas/Admin/Models/TenantDetailViewModel.cs +++ b/src/MemberCenter.Web/Areas/Admin/Models/TenantDetailViewModel.cs @@ -8,6 +8,10 @@ public sealed class TenantDetailViewModel public IReadOnlyList NewsletterLists { get; set; } = Array.Empty(); + public IReadOnlyList Managers { get; set; } = Array.Empty(); + + public bool CanManageManagers { get; set; } + public int ActiveSubscriptionCount => NewsletterLists.Sum(list => list.ActiveSubscriptionCount); } @@ -21,3 +25,14 @@ public sealed class TenantNewsletterSummaryViewModel public int ActiveSubscriptionCount { get; set; } } + +public sealed class TenantManagerItemViewModel +{ + public Guid UserId { get; set; } + + public string Email { get; set; } = string.Empty; + + public string DisplayName { get; set; } = string.Empty; + + public DateTimeOffset CreatedAt { get; set; } +} diff --git a/src/MemberCenter.Web/Areas/Admin/Views/Accounts/Details.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/Accounts/Details.cshtml index d7011c6..2ec5c48 100644 --- a/src/MemberCenter.Web/Areas/Admin/Views/Accounts/Details.cshtml +++ b/src/MemberCenter.Web/Areas/Admin/Views/Accounts/Details.cshtml @@ -72,9 +72,29 @@