diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs
index 13e8cc49..53437e27 100644
--- a/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs
+++ b/apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs
@@ -1,12 +1,16 @@
+using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace WebClientTpos.Client.Services;
+// EN: POS data service — attaches auth token for multi-tenant data isolation.
+// VI: POS data service — đính kèm auth token để cách ly dữ liệu multi-tenant.
public class PosDataService
{
private readonly HttpClient _http;
+ private readonly AuthStateService _authState;
private static readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNameCaseInsensitive = true,
@@ -14,7 +18,24 @@ public class PosDataService
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
- public PosDataService(HttpClient http) => _http = http;
+ public PosDataService(HttpClient http, AuthStateService authState)
+ {
+ _http = http;
+ _authState = authState;
+ }
+
+ ///
+ /// EN: Ensure Authorization header is set before each BFF call.
+ /// VI: Đảm bảo header Authorization được đặt trước mỗi BFF call.
+ ///
+ private void AttachToken()
+ {
+ if (!string.IsNullOrEmpty(_authState.Token))
+ {
+ _http.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue("Bearer", _authState.Token);
+ }
+ }
public record ShopInfo(Guid Id, string Name, string Slug, string? Description, string? Phone, string? Email, string? Category, string? Status);
public record ProductInfo(Guid Id, string Name, decimal Price, string? Sku, string? Description, string? Category, int? DurationMinutes);
@@ -24,25 +45,25 @@ public class PosDataService
public record StaffInfo(Guid Id, Guid? UserId, string? EmployeeCode, string? Phone, string? Email, DateTime? JoinedAt, DateTime? TerminatedAt, string? Role, string? Status, string? ShopName);
public async Task> GetShopsAsync()
- => await _http.GetFromJsonAsync>("api/bff/shops", _jsonOptions) ?? new();
+ { AttachToken(); return await _http.GetFromJsonAsync>("api/bff/shops", _jsonOptions) ?? new(); }
public async Task GetShopByIdAsync(Guid shopId)
- => await _http.GetFromJsonAsync($"api/bff/shops/{shopId}", _jsonOptions);
+ { AttachToken(); return await _http.GetFromJsonAsync($"api/bff/shops/{shopId}", _jsonOptions); }
public async Task> GetProductsAsync(Guid shopId)
- => await _http.GetFromJsonAsync>($"api/bff/shops/{shopId}/products", _jsonOptions) ?? new();
+ { AttachToken(); return await _http.GetFromJsonAsync>($"api/bff/shops/{shopId}/products", _jsonOptions) ?? new(); }
public async Task> GetCategoriesAsync(Guid shopId)
- => await _http.GetFromJsonAsync>($"api/bff/shops/{shopId}/categories", _jsonOptions) ?? new();
+ { AttachToken(); return await _http.GetFromJsonAsync>($"api/bff/shops/{shopId}/categories", _jsonOptions) ?? new(); }
public async Task> GetTablesAsync(Guid shopId)
- => await _http.GetFromJsonAsync>($"api/bff/shops/{shopId}/tables", _jsonOptions) ?? new();
+ { AttachToken(); return await _http.GetFromJsonAsync>($"api/bff/shops/{shopId}/tables", _jsonOptions) ?? new(); }
public async Task> GetAppointmentsAsync(Guid shopId)
- => await _http.GetFromJsonAsync>($"api/bff/shops/{shopId}/appointments", _jsonOptions) ?? new();
+ { AttachToken(); return await _http.GetFromJsonAsync>($"api/bff/shops/{shopId}/appointments", _jsonOptions) ?? new(); }
public async Task> GetStaffAsync()
- => await _http.GetFromJsonAsync>("api/bff/staff", _jsonOptions) ?? new();
+ { AttachToken(); return await _http.GetFromJsonAsync>("api/bff/staff", _jsonOptions) ?? new(); }
// ═══ ADMIN-LEVEL PRODUCT/CATEGORY METHODS ═══
@@ -57,24 +78,28 @@ public class PosDataService
public async Task> GetAllProductsAsync(Guid? shopId = null)
{
+ AttachToken();
var url = shopId.HasValue ? $"api/bff/products?shopId={shopId}" : "api/bff/products";
return await _http.GetFromJsonAsync>(url, _jsonOptions) ?? new();
}
public async Task> GetAllCategoriesAsync(Guid? shopId = null)
{
+ AttachToken();
var url = shopId.HasValue ? $"api/bff/categories?shopId={shopId}" : "api/bff/categories";
return await _http.GetFromJsonAsync>(url, _jsonOptions) ?? new();
}
public async Task CreateProductAsync(CreateProductRequest req)
{
+ AttachToken();
var resp = await _http.PostAsJsonAsync("api/bff/products", req, _jsonOptions);
return resp.IsSuccessStatusCode;
}
public async Task DeleteProductAsync(Guid productId)
{
+ AttachToken();
var resp = await _http.DeleteAsync($"api/bff/products/{productId}");
return resp.IsSuccessStatusCode;
}
@@ -86,6 +111,7 @@ public class PosDataService
public async Task> GetInventoryAsync(Guid? shopId = null)
{
+ AttachToken();
var url = shopId.HasValue ? $"api/bff/inventory?shopId={shopId}" : "api/bff/inventory";
return await _http.GetFromJsonAsync>(url, _jsonOptions) ?? new();
}
@@ -96,7 +122,7 @@ public class PosDataService
int CurrentLevel, int TotalExpEarned, DateTime CreatedAt, string? LevelName);
public async Task> GetMembersAsync()
- => await _http.GetFromJsonAsync>("api/bff/members", _jsonOptions) ?? new();
+ { AttachToken(); return await _http.GetFromJsonAsync>("api/bff/members", _jsonOptions) ?? new(); }
// ═══ STAFF CREATE ═══
@@ -104,6 +130,7 @@ public class PosDataService
public async Task CreateStaffAsync(CreateStaffRequest req)
{
+ AttachToken();
var resp = await _http.PostAsJsonAsync("api/bff/staff", req, _jsonOptions);
return resp.IsSuccessStatusCode;
}
@@ -115,10 +142,11 @@ public class PosDataService
string? EmployeeCode, string? Role, string? Phone);
public async Task> GetStaffRolesAsync()
- => await _http.GetFromJsonAsync>("api/bff/staff/roles", _jsonOptions) ?? new();
+ { AttachToken(); return await _http.GetFromJsonAsync>("api/bff/staff/roles", _jsonOptions) ?? new(); }
public async Task> GetStaffSchedulesAsync(Guid? shopId = null)
{
+ AttachToken();
var url = shopId.HasValue ? $"api/bff/staff/schedules?shopId={shopId}" : "api/bff/staff/schedules";
return await _http.GetFromJsonAsync>(url, _jsonOptions) ?? new();
}
@@ -129,6 +157,7 @@ public class PosDataService
public async Task> GetOrdersAsync(Guid? shopId = null)
{
+ AttachToken();
var url = shopId.HasValue ? $"api/bff/orders?shopId={shopId}" : "api/bff/orders";
return await _http.GetFromJsonAsync>(url, _jsonOptions) ?? new();
}
@@ -139,17 +168,17 @@ public class PosDataService
public record WalletTxnInfo(Guid Id, Guid WalletId, decimal Amount, string? Description, DateTime CreatedAt, string? ItemName);
public async Task> GetWalletsAsync()
- => await _http.GetFromJsonAsync>("api/bff/wallets", _jsonOptions) ?? new();
+ { AttachToken(); return await _http.GetFromJsonAsync>("api/bff/wallets", _jsonOptions) ?? new(); }
public async Task> GetWalletTransactionsAsync(int limit = 50)
- => await _http.GetFromJsonAsync>($"api/bff/wallet/transactions?limit={limit}", _jsonOptions) ?? new();
+ { AttachToken(); return await _http.GetFromJsonAsync>($"api/bff/wallet/transactions?limit={limit}", _jsonOptions) ?? new(); }
// ═══ DEVICES ═══
public record DeviceInfo(Guid Id, string? DeviceToken, string? Platform, bool IsActive, DateTime CreatedAt, string? StaffCode);
public async Task> GetDevicesAsync()
- => await _http.GetFromJsonAsync>("api/bff/devices", _jsonOptions) ?? new();
+ { AttachToken(); return await _http.GetFromJsonAsync>("api/bff/devices", _jsonOptions) ?? new(); }
// ═══ PROMOTIONS ═══
@@ -157,7 +186,7 @@ public class PosDataService
bool IsActive, string? DiscountType, decimal? DiscountValue, int VoucherCount, int RedemptionCount);
public async Task> GetPromotionsAsync()
- => await _http.GetFromJsonAsync>("api/bff/promotions", _jsonOptions) ?? new();
+ { AttachToken(); return await _http.GetFromJsonAsync>("api/bff/promotions", _jsonOptions) ?? new(); }
// ═══ INVENTORY TRANSACTIONS ═══
@@ -165,6 +194,7 @@ public class PosDataService
public async Task> GetInventoryTransactionsAsync(Guid? shopId = null)
{
+ AttachToken();
var url = shopId.HasValue ? $"api/bff/inventory/transactions?shopId={shopId}" : "api/bff/inventory/transactions";
return await _http.GetFromJsonAsync>(url, _jsonOptions) ?? new();
}
@@ -174,12 +204,12 @@ public class PosDataService
public record LevelDefinitionInfo(Guid Id, int Level, string Name, int MinExp, int MaxExp, int MemberCount);
public async Task> GetMembershipLevelsAsync()
- => await _http.GetFromJsonAsync>("api/bff/membership/levels", _jsonOptions) ?? new();
+ { AttachToken(); return await _http.GetFromJsonAsync>("api/bff/membership/levels", _jsonOptions) ?? new(); }
// ═══ SHOP STATS (aggregated per-shop) ═══
public record ShopStatsInfo(Guid ShopId, int ProductCount, int OrderCount, int StaffCount, decimal Revenue);
public async Task> GetShopStatsAsync()
- => await _http.GetFromJsonAsync>("api/bff/shops/stats", _jsonOptions) ?? new();
+ { AttachToken(); return await _http.GetFromJsonAsync>("api/bff/shops/stats", _jsonOptions) ?? new(); }
}
diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/BffDataController.cs b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/BffDataController.cs
index 9df71c72..cd2bcc5b 100644
--- a/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/BffDataController.cs
+++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/BffDataController.cs
@@ -1,9 +1,16 @@
using Microsoft.AspNetCore.Mvc;
using Npgsql;
using Dapper;
+using System.IdentityModel.Tokens.Jwt;
namespace WebClientTpos.Server.Controllers;
+// EN: BFF controller with multi-tenant data isolation.
+// All queries are scoped to the current user's merchant.
+// JWT token is parsed manually from Authorization header (no middleware dependency).
+// VI: BFF controller với cách ly dữ liệu multi-tenant.
+// Tất cả queries đều lọc theo merchant của user hiện tại.
+// JWT token được parse thủ công từ header Authorization (không phụ thuộc middleware).
[ApiController]
[Route("api/bff")]
public class BffDataController : ControllerBase
@@ -18,9 +25,72 @@ public class BffDataController : ControllerBase
private static string ConnStr(string db) =>
$"Host={_dbHost};Port={_dbPort};Database={db};Username={_dbUser};Password={_dbPass}";
+ // ═══ TENANT RESOLUTION HELPERS ═══
+
+ ///
+ /// EN: Extract user ID from JWT token in Authorization header.
+ /// Parses the token manually without middleware validation.
+ /// VI: Trích xuất user ID từ JWT token trong header Authorization.
+ /// Parse token thủ công không cần middleware validation.
+ ///
+ private Guid? GetUserIdFromToken()
+ {
+ var authHeader = Request.Headers["Authorization"].FirstOrDefault();
+ if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
+ return null;
+
+ var tokenStr = authHeader["Bearer ".Length..].Trim();
+ try
+ {
+ var handler = new JwtSecurityTokenHandler();
+ var jwt = handler.ReadJwtToken(tokenStr);
+ var sub = jwt.Claims.FirstOrDefault(c => c.Type == "sub")?.Value;
+ if (!string.IsNullOrEmpty(sub) && Guid.TryParse(sub, out var userId))
+ return userId;
+ }
+ catch { /* Invalid token — return null */ }
+
+ return null;
+ }
+
+ ///
+ /// EN: Extract current user's merchant ID from JWT → merchants table.
+ /// VI: Lấy merchant ID của user hiện tại từ JWT → bảng merchants.
+ ///
+ private async Task GetCurrentMerchantIdAsync()
+ {
+ var userId = GetUserIdFromToken();
+ if (userId == null)
+ return null;
+
+ await using var conn = new NpgsqlConnection(ConnStr("merchant_service"));
+ return await conn.QueryFirstOrDefaultAsync(
+ "SELECT id FROM merchants WHERE user_id = @UserId AND is_deleted = false",
+ new { UserId = userId });
+ }
+
+ ///
+ /// EN: Get list of shop IDs owned by the current merchant.
+ /// VI: Lấy danh sách shop IDs thuộc sở hữu của merchant hiện tại.
+ ///
+ private async Task> GetMyShopIdsAsync(Guid merchantId)
+ {
+ await using var conn = new NpgsqlConnection(ConnStr("merchant_service"));
+ var ids = await conn.QueryAsync(
+ "SELECT id FROM shops WHERE merchant_id = @MerchantId AND is_deleted = false",
+ new { MerchantId = merchantId });
+ return ids.ToList();
+ }
+
+ // ═══ SHOP ENDPOINTS ═══
+
[HttpGet("shops")]
public async Task GetShops()
{
+ var merchantId = await GetCurrentMerchantIdAsync();
+ if (merchantId == null)
+ return Ok(Array.Empty