using System.Text.Json; using Microsoft.EntityFrameworkCore; using Meezi.Core.Constants; using Meezi.Core.Enums; using Meezi.Core.Interfaces; using Meezi.Infrastructure.Data; using Meezi.Shared; namespace Meezi.API.Middleware; public class TenantMiddleware { private static readonly string[] PublicPrefixes = [ "/api/auth", "/api/public", "/api/q/", "/api/webhooks", "/api/billing/verify", "/hubs/guest-order", "/health", "/swagger", "/hangfire" ]; private readonly RequestDelegate _next; private readonly ILogger _logger; public TenantMiddleware(RequestDelegate next, ILogger logger) { _next = next; _logger = logger; } public async Task InvokeAsync( HttpContext context, ITenantContext tenant, IBranchContext branchContext, AppDbContext db) { if (IsPublicPath(context.Request.Path)) { await _next(context); return; } if (context.User.Identity?.IsAuthenticated != true) { await WriteUnauthorizedAsync(context, "UNAUTHORIZED", "Authentication required."); return; } var actor = context.User.FindFirst(MeeziClaimTypes.Actor)?.Value; var pathValue = context.Request.Path.Value ?? string.Empty; if (actor == MeeziActorKinds.Consumer) { if (pathValue.StartsWith("/api/customers/me", StringComparison.OrdinalIgnoreCase)) { await _next(context); return; } await WriteForbiddenAsync(context, "FORBIDDEN", "Consumer access is limited to account endpoints."); return; } if (tenant is TenantContext scopedTenant) { scopedTenant.UserId = context.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? context.User.FindFirst("sub")?.Value; scopedTenant.Language = context.User.FindFirst(MeeziClaimTypes.Language)?.Value ?? "fa"; } var cafeId = context.User.FindFirst(MeeziClaimTypes.CafeId)?.Value; if (string.IsNullOrEmpty(cafeId)) { _logger.LogWarning("Authenticated request missing cafeId claim for {Path}", context.Request.Path); await WriteUnauthorizedAsync(context, "UNAUTHORIZED", "Cafe context is missing."); return; } var cafeSuspended = await db.Cafes .AsNoTracking() .AnyAsync(c => c.Id == cafeId && c.IsSuspended, context.RequestAborted); if (cafeSuspended) { await WriteForbiddenAsync(context, "CAFE_SUSPENDED", "This cafe account is suspended. Contact Meezi support."); return; } if (tenant is TenantContext scopedMerchant) { scopedMerchant.CafeId = cafeId; // .NET's JWT handler remaps the short "role" claim to ClaimTypes.Role // on inbound, so FindFirst("role") returns null and tenant.Role would // stay null — making EnsureManager/EnsureOwner reject even a real owner. // Read both the raw claim and the mapped one. var roleClaim = context.User.FindFirst(MeeziClaimTypes.Role)?.Value ?? context.User.FindFirst(System.Security.Claims.ClaimTypes.Role)?.Value; if (Enum.TryParse(roleClaim, ignoreCase: true, out var role)) scopedMerchant.Role = role; var planClaim = context.User.FindFirst(MeeziClaimTypes.PlanTier)?.Value; if (Enum.TryParse(planClaim, ignoreCase: true, out var plan)) scopedMerchant.PlanTier = plan; var branchIdClaim = context.User.FindFirst(MeeziClaimTypes.BranchId)?.Value; if (!string.IsNullOrEmpty(branchIdClaim)) { var branchValid = await db.Branches.AnyAsync( b => b.Id == branchIdClaim && b.CafeId == cafeId && b.IsActive, context.RequestAborted); if (branchValid) scopedMerchant.BranchId = branchIdClaim; else _logger.LogWarning("Ignoring invalid or inactive branchId claim for cafe {CafeId}", cafeId); } } if (branchContext is BranchContext scopedBranch) { scopedBranch.CafeId = cafeId; if (tenant is TenantContext scopedTenantBranch && !string.IsNullOrEmpty(scopedTenantBranch.BranchId)) scopedBranch.BranchId = scopedTenantBranch.BranchId; } await _next(context); } private static bool IsPublicPath(PathString path) { var value = path.Value ?? string.Empty; return PublicPrefixes.Any(prefix => value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); } private static async Task WriteUnauthorizedAsync(HttpContext context, string code, string message) { context.Response.StatusCode = StatusCodes.Status401Unauthorized; context.Response.ContentType = "application/json"; var payload = new ApiResponse(false, null, new ApiError(code, message)); await context.Response.WriteAsync(JsonSerializer.Serialize(payload, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase })); } private static async Task WriteForbiddenAsync(HttpContext context, string code, string message) { context.Response.StatusCode = StatusCodes.Status403Forbidden; context.Response.ContentType = "application/json"; var payload = new ApiResponse(false, null, new ApiError(code, message)); await context.Response.WriteAsync(JsonSerializer.Serialize(payload, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase })); } }