fix(web-client-tpos): add multi-tenant data isolation to BFF controller

- Implement manual JWT parsing from Authorization header in BffDataController
- Add GetUserIdFromToken() and GetCurrentMerchantIdAsync() helpers
- Scope all 15 BFF endpoints by merchant ownership (shops, products, orders, staff, inventory, wallets, stats)
- Validate ownership on write operations (CreateProduct, CreateStaff, DeleteProduct)
- Add AttachToken() to all 23 PosDataService methods to forward auth token to BFF
- Add JwtSecurityTokenHandler NuGet package for token decoding
This commit is contained in:
Ho Ngoc Hai
2026-02-28 12:20:17 +07:00
parent f8c2e65d2b
commit c838d3627b
3 changed files with 321 additions and 107 deletions

View File

@@ -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;
}
/// <summary>
/// EN: Ensure Authorization header is set before each BFF call.
/// VI: Đảm bảo header Authorization được đặt trước mỗi BFF call.
/// </summary>
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<List<ShopInfo>> GetShopsAsync()
=> await _http.GetFromJsonAsync<List<ShopInfo>>("api/bff/shops", _jsonOptions) ?? new();
{ AttachToken(); return await _http.GetFromJsonAsync<List<ShopInfo>>("api/bff/shops", _jsonOptions) ?? new(); }
public async Task<ShopInfo?> GetShopByIdAsync(Guid shopId)
=> await _http.GetFromJsonAsync<ShopInfo>($"api/bff/shops/{shopId}", _jsonOptions);
{ AttachToken(); return await _http.GetFromJsonAsync<ShopInfo>($"api/bff/shops/{shopId}", _jsonOptions); }
public async Task<List<ProductInfo>> GetProductsAsync(Guid shopId)
=> await _http.GetFromJsonAsync<List<ProductInfo>>($"api/bff/shops/{shopId}/products", _jsonOptions) ?? new();
{ AttachToken(); return await _http.GetFromJsonAsync<List<ProductInfo>>($"api/bff/shops/{shopId}/products", _jsonOptions) ?? new(); }
public async Task<List<CategoryInfo>> GetCategoriesAsync(Guid shopId)
=> await _http.GetFromJsonAsync<List<CategoryInfo>>($"api/bff/shops/{shopId}/categories", _jsonOptions) ?? new();
{ AttachToken(); return await _http.GetFromJsonAsync<List<CategoryInfo>>($"api/bff/shops/{shopId}/categories", _jsonOptions) ?? new(); }
public async Task<List<TableInfo>> GetTablesAsync(Guid shopId)
=> await _http.GetFromJsonAsync<List<TableInfo>>($"api/bff/shops/{shopId}/tables", _jsonOptions) ?? new();
{ AttachToken(); return await _http.GetFromJsonAsync<List<TableInfo>>($"api/bff/shops/{shopId}/tables", _jsonOptions) ?? new(); }
public async Task<List<AppointmentInfo>> GetAppointmentsAsync(Guid shopId)
=> await _http.GetFromJsonAsync<List<AppointmentInfo>>($"api/bff/shops/{shopId}/appointments", _jsonOptions) ?? new();
{ AttachToken(); return await _http.GetFromJsonAsync<List<AppointmentInfo>>($"api/bff/shops/{shopId}/appointments", _jsonOptions) ?? new(); }
public async Task<List<StaffInfo>> GetStaffAsync()
=> await _http.GetFromJsonAsync<List<StaffInfo>>("api/bff/staff", _jsonOptions) ?? new();
{ AttachToken(); return await _http.GetFromJsonAsync<List<StaffInfo>>("api/bff/staff", _jsonOptions) ?? new(); }
// ═══ ADMIN-LEVEL PRODUCT/CATEGORY METHODS ═══
@@ -57,24 +78,28 @@ public class PosDataService
public async Task<List<AdminProductInfo>> GetAllProductsAsync(Guid? shopId = null)
{
AttachToken();
var url = shopId.HasValue ? $"api/bff/products?shopId={shopId}" : "api/bff/products";
return await _http.GetFromJsonAsync<List<AdminProductInfo>>(url, _jsonOptions) ?? new();
}
public async Task<List<AdminCategoryInfo>> GetAllCategoriesAsync(Guid? shopId = null)
{
AttachToken();
var url = shopId.HasValue ? $"api/bff/categories?shopId={shopId}" : "api/bff/categories";
return await _http.GetFromJsonAsync<List<AdminCategoryInfo>>(url, _jsonOptions) ?? new();
}
public async Task<bool> CreateProductAsync(CreateProductRequest req)
{
AttachToken();
var resp = await _http.PostAsJsonAsync("api/bff/products", req, _jsonOptions);
return resp.IsSuccessStatusCode;
}
public async Task<bool> 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<List<InventoryItemInfo>> GetInventoryAsync(Guid? shopId = null)
{
AttachToken();
var url = shopId.HasValue ? $"api/bff/inventory?shopId={shopId}" : "api/bff/inventory";
return await _http.GetFromJsonAsync<List<InventoryItemInfo>>(url, _jsonOptions) ?? new();
}
@@ -96,7 +122,7 @@ public class PosDataService
int CurrentLevel, int TotalExpEarned, DateTime CreatedAt, string? LevelName);
public async Task<List<MemberInfo>> GetMembersAsync()
=> await _http.GetFromJsonAsync<List<MemberInfo>>("api/bff/members", _jsonOptions) ?? new();
{ AttachToken(); return await _http.GetFromJsonAsync<List<MemberInfo>>("api/bff/members", _jsonOptions) ?? new(); }
// ═══ STAFF CREATE ═══
@@ -104,6 +130,7 @@ public class PosDataService
public async Task<bool> 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<List<StaffRoleInfo>> GetStaffRolesAsync()
=> await _http.GetFromJsonAsync<List<StaffRoleInfo>>("api/bff/staff/roles", _jsonOptions) ?? new();
{ AttachToken(); return await _http.GetFromJsonAsync<List<StaffRoleInfo>>("api/bff/staff/roles", _jsonOptions) ?? new(); }
public async Task<List<ScheduleInfo>> GetStaffSchedulesAsync(Guid? shopId = null)
{
AttachToken();
var url = shopId.HasValue ? $"api/bff/staff/schedules?shopId={shopId}" : "api/bff/staff/schedules";
return await _http.GetFromJsonAsync<List<ScheduleInfo>>(url, _jsonOptions) ?? new();
}
@@ -129,6 +157,7 @@ public class PosDataService
public async Task<List<OrderInfo>> GetOrdersAsync(Guid? shopId = null)
{
AttachToken();
var url = shopId.HasValue ? $"api/bff/orders?shopId={shopId}" : "api/bff/orders";
return await _http.GetFromJsonAsync<List<OrderInfo>>(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<List<WalletInfo>> GetWalletsAsync()
=> await _http.GetFromJsonAsync<List<WalletInfo>>("api/bff/wallets", _jsonOptions) ?? new();
{ AttachToken(); return await _http.GetFromJsonAsync<List<WalletInfo>>("api/bff/wallets", _jsonOptions) ?? new(); }
public async Task<List<WalletTxnInfo>> GetWalletTransactionsAsync(int limit = 50)
=> await _http.GetFromJsonAsync<List<WalletTxnInfo>>($"api/bff/wallet/transactions?limit={limit}", _jsonOptions) ?? new();
{ AttachToken(); return await _http.GetFromJsonAsync<List<WalletTxnInfo>>($"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<List<DeviceInfo>> GetDevicesAsync()
=> await _http.GetFromJsonAsync<List<DeviceInfo>>("api/bff/devices", _jsonOptions) ?? new();
{ AttachToken(); return await _http.GetFromJsonAsync<List<DeviceInfo>>("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<List<PromotionInfo>> GetPromotionsAsync()
=> await _http.GetFromJsonAsync<List<PromotionInfo>>("api/bff/promotions", _jsonOptions) ?? new();
{ AttachToken(); return await _http.GetFromJsonAsync<List<PromotionInfo>>("api/bff/promotions", _jsonOptions) ?? new(); }
// ═══ INVENTORY TRANSACTIONS ═══
@@ -165,6 +194,7 @@ public class PosDataService
public async Task<List<InventoryTxnInfo>> GetInventoryTransactionsAsync(Guid? shopId = null)
{
AttachToken();
var url = shopId.HasValue ? $"api/bff/inventory/transactions?shopId={shopId}" : "api/bff/inventory/transactions";
return await _http.GetFromJsonAsync<List<InventoryTxnInfo>>(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<List<LevelDefinitionInfo>> GetMembershipLevelsAsync()
=> await _http.GetFromJsonAsync<List<LevelDefinitionInfo>>("api/bff/membership/levels", _jsonOptions) ?? new();
{ AttachToken(); return await _http.GetFromJsonAsync<List<LevelDefinitionInfo>>("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<List<ShopStatsInfo>> GetShopStatsAsync()
=> await _http.GetFromJsonAsync<List<ShopStatsInfo>>("api/bff/shops/stats", _jsonOptions) ?? new();
{ AttachToken(); return await _http.GetFromJsonAsync<List<ShopStatsInfo>>("api/bff/shops/stats", _jsonOptions) ?? new(); }
}

View File

@@ -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 ═══
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
private async Task<Guid?> GetCurrentMerchantIdAsync()
{
var userId = GetUserIdFromToken();
if (userId == null)
return null;
await using var conn = new NpgsqlConnection(ConnStr("merchant_service"));
return await conn.QueryFirstOrDefaultAsync<Guid?>(
"SELECT id FROM merchants WHERE user_id = @UserId AND is_deleted = false",
new { UserId = userId });
}
/// <summary>
/// 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.
/// </summary>
private async Task<List<Guid>> GetMyShopIdsAsync(Guid merchantId)
{
await using var conn = new NpgsqlConnection(ConnStr("merchant_service"));
var ids = await conn.QueryAsync<Guid>(
"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<IActionResult> GetShops()
{
var merchantId = await GetCurrentMerchantIdAsync();
if (merchantId == null)
return Ok(Array.Empty<object>()); // EN: No merchant → no shops / VI: Không có merchant → không có shops
await using var conn = new NpgsqlConnection(ConnStr("merchant_service"));
var shops = await conn.QueryAsync<dynamic>(
@"SELECT s.id, s.name, s.slug, s.description, s.phone, s.email,
@@ -29,14 +99,19 @@ public class BffDataController : ControllerBase
FROM shops s
JOIN business_categories bc ON s.category_id = bc.id
JOIN shop_statuses st ON s.status_id = st.id
WHERE s.is_deleted = false
ORDER BY s.name");
WHERE s.merchant_id = @MerchantId AND s.is_deleted = false
ORDER BY s.name",
new { MerchantId = merchantId });
return Ok(shops);
}
[HttpGet("shops/{shopId:guid}")]
public async Task<IActionResult> GetShopById(Guid shopId)
{
var merchantId = await GetCurrentMerchantIdAsync();
if (merchantId == null)
return NotFound(new { message = "Shop not found" });
await using var conn = new NpgsqlConnection(ConnStr("merchant_service"));
var shop = await conn.QueryFirstOrDefaultAsync<dynamic>(
@"SELECT s.id, s.name, s.slug, s.description, s.phone, s.email,
@@ -45,8 +120,8 @@ public class BffDataController : ControllerBase
FROM shops s
JOIN business_categories bc ON s.category_id = bc.id
JOIN shop_statuses st ON s.status_id = st.id
WHERE s.id = @ShopId AND s.is_deleted = false",
new { ShopId = shopId });
WHERE s.id = @ShopId AND s.merchant_id = @MerchantId AND s.is_deleted = false",
new { ShopId = shopId, MerchantId = merchantId });
if (shop == null)
return NotFound(new { message = "Shop not found" });
@@ -57,6 +132,10 @@ public class BffDataController : ControllerBase
[HttpGet("staff")]
public async Task<IActionResult> GetStaff()
{
var merchantId = await GetCurrentMerchantIdAsync();
if (merchantId == null)
return Ok(Array.Empty<object>());
await using var conn = new NpgsqlConnection(ConnStr("merchant_service"));
var staff = await conn.QueryAsync<dynamic>(
@"SELECT ms.id, ms.user_id, ms.employee_code, ms.phone, ms.email,
@@ -68,7 +147,9 @@ public class BffDataController : ControllerBase
JOIN staff_statuses ss ON ms.status_id = ss.id
LEFT JOIN shop_members sm ON sm.staff_id = ms.id
LEFT JOIN shops s ON sm.shop_id = s.id
ORDER BY ms.joined_at DESC");
WHERE ms.merchant_id = @MerchantId
ORDER BY ms.joined_at DESC",
new { MerchantId = merchantId });
return Ok(staff);
}
@@ -150,54 +231,88 @@ public class BffDataController : ControllerBase
return Ok(resources);
}
// ═══ ADMIN-LEVEL PRODUCT ENDPOINTS ═══
// ═══ MERCHANT-SCOPED PRODUCT ENDPOINTS ═══
/// <summary>
/// EN: Get all products across all shops (admin level).
/// VI: Lấy tất cả sản phẩm trên tất cả cửa hàng (cấp admin).
/// EN: Get products belonging to the current merchant's shops.
/// VI: Lấy sản phẩm thuộc các cửa hàng của merchant hiện tại.
/// </summary>
[HttpGet("products")]
public async Task<IActionResult> GetAllProducts([FromQuery] Guid? shopId = null)
{
var merchantId = await GetCurrentMerchantIdAsync();
if (merchantId == null)
return Ok(Array.Empty<object>());
var myShopIds = await GetMyShopIdsAsync(merchantId.Value);
if (!myShopIds.Any())
return Ok(Array.Empty<object>());
// EN: If shopId specified, verify ownership / VI: Nếu có shopId, kiểm tra quyền sở hữu
if (shopId.HasValue && !myShopIds.Contains(shopId.Value))
return Ok(Array.Empty<object>());
var targetShopIds = shopId.HasValue ? new List<Guid> { shopId.Value } : myShopIds;
await using var conn = new NpgsqlConnection(ConnStr("catalog_service"));
var sql = @"SELECT p.id, p.name, p.price, p.sku, p.description, p.image_url,
p.is_active, pt.name as type, p.shop_id, p.created_at,
'' as category_name
FROM products p
JOIN product_types pt ON p.type_id = pt.id";
if (shopId.HasValue)
sql += " WHERE p.shop_id = @ShopId";
sql += " ORDER BY p.name";
var products = await conn.QueryAsync<dynamic>(sql, new { ShopId = shopId });
var products = await conn.QueryAsync<dynamic>(
@"SELECT p.id, p.name, p.price, p.sku, p.description, p.image_url,
p.is_active, pt.name as type, p.shop_id, p.created_at,
'' as category_name
FROM products p
JOIN product_types pt ON p.type_id = pt.id
WHERE p.shop_id = ANY(@ShopIds)
ORDER BY p.name",
new { ShopIds = targetShopIds.ToArray() });
return Ok(products);
}
/// <summary>
/// EN: Get all categories across all shops (admin level).
/// VI: Lấy tất cả danh mục trên tất cả cửa hàng (cấp admin).
/// EN: Get categories belonging to the current merchant's shops.
/// VI: Lấy danh mục thuộc các cửa hàng của merchant hiện tại.
/// </summary>
[HttpGet("categories")]
public async Task<IActionResult> GetAllCategories([FromQuery] Guid? shopId = null)
{
var merchantId = await GetCurrentMerchantIdAsync();
if (merchantId == null)
return Ok(Array.Empty<object>());
var myShopIds = await GetMyShopIdsAsync(merchantId.Value);
if (!myShopIds.Any())
return Ok(Array.Empty<object>());
if (shopId.HasValue && !myShopIds.Contains(shopId.Value))
return Ok(Array.Empty<object>());
var targetShopIds = shopId.HasValue ? new List<Guid> { shopId.Value } : myShopIds;
await using var conn = new NpgsqlConnection(ConnStr("catalog_service"));
var sql = @"SELECT id, name, description, display_order, shop_id, parent_id, is_active
FROM categories WHERE is_active = true";
if (shopId.HasValue)
sql += " AND shop_id = @ShopId";
sql += " ORDER BY display_order, name";
var categories = await conn.QueryAsync<dynamic>(sql, new { ShopId = shopId });
var categories = await conn.QueryAsync<dynamic>(
@"SELECT id, name, description, display_order, shop_id, parent_id, is_active
FROM categories
WHERE is_active = true AND shop_id = ANY(@ShopIds)
ORDER BY display_order, name",
new { ShopIds = targetShopIds.ToArray() });
return Ok(categories);
}
/// <summary>
/// EN: Create a product via BFF (writes directly to catalog DB).
/// VI: Tạo sản phẩm qua BFF (ghi trực tiếp vào catalog DB).
/// EN: Create a product — validates shop ownership first.
/// VI: Tạo sản phẩm — kiểm tra quyền sở hữu shop trước.
/// </summary>
[HttpPost("products")]
public async Task<IActionResult> CreateProduct([FromBody] CreateProductRequest req)
{
var merchantId = await GetCurrentMerchantIdAsync();
if (merchantId == null)
return Forbid();
var myShopIds = await GetMyShopIdsAsync(merchantId.Value);
if (!myShopIds.Contains(req.ShopId))
return Forbid(); // EN: Cannot create product in another merchant's shop
var id = Guid.NewGuid();
// EN: Map type string to type_id / VI: Chuyển type string sang type_id
var typeId = (req.Type ?? "PreparedFood") switch
{
"Physical" => 1,
@@ -214,16 +329,24 @@ public class BffDataController : ControllerBase
}
/// <summary>
/// EN: Delete (deactivate) a product.
/// VI: Xóa (vô hiệu hóa) sản phẩm.
/// EN: Delete (deactivate) a product — validates ownership first.
/// VI: Xóa (vô hiệu hóa) sản phẩm — kiểm tra quyền sở hữu trước.
/// </summary>
[HttpDelete("products/{productId:guid}")]
public async Task<IActionResult> DeleteProduct(Guid productId)
{
var merchantId = await GetCurrentMerchantIdAsync();
if (merchantId == null)
return Forbid();
var myShopIds = await GetMyShopIdsAsync(merchantId.Value);
await using var conn = new NpgsqlConnection(ConnStr("catalog_service"));
// EN: Only delete if product belongs to one of merchant's shops
// VI: Chỉ xóa nếu sản phẩm thuộc shop của merchant
await conn.ExecuteAsync(
"UPDATE products SET is_active = false WHERE id = @Id",
new { Id = productId });
"UPDATE products SET is_active = false WHERE id = @Id AND shop_id = ANY(@ShopIds)",
new { Id = productId, ShopIds = myShopIds.ToArray() });
return NoContent();
}
@@ -236,18 +359,33 @@ public class BffDataController : ControllerBase
[HttpGet("inventory")]
public async Task<IActionResult> GetInventory([FromQuery] Guid? shopId = null)
{
await using var conn = new NpgsqlConnection(ConnStr("inventory_service"));
var sql = @"SELECT id, product_id, shop_id, quantity, reorder_level, reserved_quantity, updated_at
FROM inventory_items";
if (shopId.HasValue)
sql += " WHERE shop_id = @ShopId";
sql += " ORDER BY quantity ASC";
var items = await conn.QueryAsync<dynamic>(sql, new { ShopId = shopId });
var merchantId = await GetCurrentMerchantIdAsync();
if (merchantId == null)
return Ok(Array.Empty<object>());
// EN: Enrich with product names from catalog_service
// VI: Bổ sung tên sản phẩm từ catalog_service
var myShopIds = await GetMyShopIdsAsync(merchantId.Value);
if (!myShopIds.Any())
return Ok(Array.Empty<object>());
if (shopId.HasValue && !myShopIds.Contains(shopId.Value))
return Ok(Array.Empty<object>());
var targetShopIds = shopId.HasValue ? new List<Guid> { shopId.Value } : myShopIds;
await using var conn = new NpgsqlConnection(ConnStr("inventory_service"));
var items = await conn.QueryAsync<dynamic>(
@"SELECT id, product_id, shop_id, quantity, reorder_level, reserved_quantity, updated_at
FROM inventory_items
WHERE shop_id = ANY(@ShopIds)
ORDER BY quantity ASC",
new { ShopIds = targetShopIds.ToArray() });
// EN: Enrich with product names from catalog_service (scoped)
// VI: Bổ sung tên sản phẩm từ catalog_service (đã lọc)
await using var catConn = new NpgsqlConnection(ConnStr("catalog_service"));
var products = (await catConn.QueryAsync<dynamic>("SELECT id, name FROM products")).ToList();
var products = (await catConn.QueryAsync<dynamic>(
"SELECT id, name FROM products WHERE shop_id = ANY(@ShopIds)",
new { ShopIds = targetShopIds.ToArray() })).ToList();
var prodMap = products.ToDictionary(p => (Guid)p.id, p => (string)p.name);
var result = items.Select(i => new
@@ -282,19 +420,22 @@ public class BffDataController : ControllerBase
// ═══ STAFF CREATE ENDPOINT ═══
/// <summary>
/// EN: Create a staff member.
/// VI: Tạo nhân viên mới.
/// EN: Create a staff member — validates merchant ownership.
/// VI: Tạo nhân viên mới — kiểm tra quyền sở hữu merchant.
/// </summary>
[HttpPost("staff")]
public async Task<IActionResult> CreateStaff([FromBody] CreateStaffRequest req)
{
var merchantId = await GetCurrentMerchantIdAsync();
if (merchantId == null || merchantId.Value != req.MerchantId)
return Forbid(); // EN: Cannot create staff for another merchant
var id = Guid.NewGuid();
await using var conn = new NpgsqlConnection(ConnStr("merchant_service"));
// EN: Get default role and status IDs / VI: Lấy ID vai trò và trạng thái mặc định
var roleId = await conn.QueryFirstOrDefaultAsync<int>(
"SELECT id FROM staff_roles WHERE name = @Role", new { req.Role }) ;
if (roleId == 0) roleId = 1; // default to first role
if (roleId == 0) roleId = 1;
var statusId = await conn.QueryFirstOrDefaultAsync<int>(
"SELECT id FROM staff_statuses WHERE name = 'Active'");
@@ -344,41 +485,68 @@ public class BffDataController : ControllerBase
[HttpGet("orders")]
public async Task<IActionResult> GetOrders([FromQuery] Guid? shopId = null)
{
var merchantId = await GetCurrentMerchantIdAsync();
if (merchantId == null)
return Ok(Array.Empty<object>());
var myShopIds = await GetMyShopIdsAsync(merchantId.Value);
if (!myShopIds.Any())
return Ok(Array.Empty<object>());
if (shopId.HasValue && !myShopIds.Contains(shopId.Value))
return Ok(Array.Empty<object>());
var targetShopIds = shopId.HasValue ? new List<Guid> { shopId.Value } : myShopIds;
await using var conn = new NpgsqlConnection(ConnStr("order_service"));
var sql = @"SELECT o.id, o.shop_id, o.total_amount, o.status_id, o.created_at,
os.name as status
FROM orders o
JOIN order_statuses os ON o.status_id = os.id";
if (shopId.HasValue) sql += " WHERE o.shop_id = @ShopId";
sql += " ORDER BY o.created_at DESC LIMIT 200";
var orders = await conn.QueryAsync<dynamic>(sql, new { ShopId = shopId });
var orders = await conn.QueryAsync<dynamic>(
@"SELECT o.id, o.shop_id, o.total_amount, o.status_id, o.created_at,
os.name as status
FROM orders o
JOIN order_statuses os ON o.status_id = os.id
WHERE o.shop_id = ANY(@ShopIds)
ORDER BY o.created_at DESC LIMIT 200",
new { ShopIds = targetShopIds.ToArray() });
return Ok(orders);
}
// ═══ WALLET/FINANCE ═══
// ═══ WALLET/FINANCE (scoped by merchant owner_id) ═══
[HttpGet("wallets")]
public async Task<IActionResult> GetWallets()
{
var merchantId = await GetCurrentMerchantIdAsync();
if (merchantId == null)
return Ok(Array.Empty<object>());
await using var conn = new NpgsqlConnection(ConnStr("wallet_service"));
var wallets = await conn.QueryAsync<dynamic>(
@"SELECT w.id, w.balance, w.currency, w.owner_id, w.created_at,
(SELECT COALESCE(SUM(amount),0) FROM wallet_transactions wt WHERE wt.wallet_id = w.id AND wt.amount > 0) as total_income,
(SELECT COALESCE(SUM(ABS(amount)),0) FROM wallet_transactions wt WHERE wt.wallet_id = w.id AND wt.amount < 0) as total_expense
FROM wallets w ORDER BY w.created_at DESC");
FROM wallets w
WHERE w.owner_id = @MerchantId::text
ORDER BY w.created_at DESC",
new { MerchantId = merchantId });
return Ok(wallets);
}
[HttpGet("wallet/transactions")]
public async Task<IActionResult> GetWalletTransactions([FromQuery] int limit = 50)
{
var merchantId = await GetCurrentMerchantIdAsync();
if (merchantId == null)
return Ok(Array.Empty<object>());
await using var conn = new NpgsqlConnection(ConnStr("wallet_service"));
var txns = await conn.QueryAsync<dynamic>(
@"SELECT wt.id, wt.wallet_id, wt.amount, wt.description, wt.created_at,
wi.name as item_name
FROM wallet_transactions wt
JOIN wallets w ON wt.wallet_id = w.id
LEFT JOIN wallet_items wi ON wt.reference_id = wi.id
WHERE w.owner_id = @MerchantId::text
ORDER BY wt.created_at DESC LIMIT @Limit",
new { Limit = limit });
new { MerchantId = merchantId, Limit = limit });
return Ok(txns);
}
@@ -413,15 +581,29 @@ public class BffDataController : ControllerBase
[HttpGet("inventory/transactions")]
public async Task<IActionResult> GetInventoryTransactions([FromQuery] Guid? shopId = null)
{
var merchantId = await GetCurrentMerchantIdAsync();
if (merchantId == null)
return Ok(Array.Empty<object>());
var myShopIds = await GetMyShopIdsAsync(merchantId.Value);
if (!myShopIds.Any())
return Ok(Array.Empty<object>());
if (shopId.HasValue && !myShopIds.Contains(shopId.Value))
return Ok(Array.Empty<object>());
var targetShopIds = shopId.HasValue ? new List<Guid> { shopId.Value } : myShopIds;
await using var conn = new NpgsqlConnection(ConnStr("inventory_service"));
var sql = @"SELECT it.id, it.inventory_item_id, it.quantity_change, it.reason, it.created_at,
tt.name as transaction_type
FROM inventory_transactions it
JOIN transaction_types tt ON it.type_id = tt.id";
if (shopId.HasValue)
sql += @" JOIN inventory_items ii ON it.inventory_item_id = ii.id WHERE ii.shop_id = @ShopId";
sql += " ORDER BY it.created_at DESC LIMIT 100";
var txns = await conn.QueryAsync<dynamic>(sql, new { ShopId = shopId });
var txns = await conn.QueryAsync<dynamic>(
@"SELECT it.id, it.inventory_item_id, it.quantity_change, it.reason, it.created_at,
tt.name as transaction_type
FROM inventory_transactions it
JOIN transaction_types tt ON it.type_id = tt.id
JOIN inventory_items ii ON it.inventory_item_id = ii.id
WHERE ii.shop_id = ANY(@ShopIds)
ORDER BY it.created_at DESC LIMIT 100",
new { ShopIds = targetShopIds.ToArray() });
return Ok(txns);
}
@@ -437,38 +619,47 @@ public class BffDataController : ControllerBase
return Ok(levels);
}
// ═══ SHOP STATS (aggregated per-shop counts) ═══
// ═══ SHOP STATS (merchant-scoped per-shop counts) ═══
/// <summary>
/// EN: Get aggregated stats per shop — product count, order count, staff count, revenue.
/// VI: Lấy thống kê tổng hợp theo shop — số sản phẩm, đơn hàng, nhân viên, doanh thu.
/// EN: Get aggregated stats per shop — scoped to current merchant.
/// VI: Lấy thống kê tổng hợp theo shop — lọc theo merchant hiện tại.
/// </summary>
[HttpGet("shops/stats")]
public async Task<IActionResult> GetShopStats()
{
// EN: Collect stats from multiple service databases
// VI: Thu thập stats từ nhiều database dịch vụ
var merchantId = await GetCurrentMerchantIdAsync();
if (merchantId == null)
return Ok(Array.Empty<object>());
// Products per shop
var myShopIds = await GetMyShopIdsAsync(merchantId.Value);
if (!myShopIds.Any())
return Ok(Array.Empty<object>());
var shopIdsArray = myShopIds.ToArray();
// Products per shop (scoped)
Dictionary<Guid, int> productCounts = new();
try
{
await using var catConn = new NpgsqlConnection(ConnStr("catalog_service"));
var prodStats = await catConn.QueryAsync<dynamic>(
"SELECT shop_id, COUNT(*) as cnt FROM products WHERE is_active = true GROUP BY shop_id");
"SELECT shop_id, COUNT(*) as cnt FROM products WHERE is_active = true AND shop_id = ANY(@ShopIds) GROUP BY shop_id",
new { ShopIds = shopIdsArray });
foreach (var ps in prodStats)
productCounts[(Guid)ps.shop_id] = (int)(long)ps.cnt;
}
catch { /* catalog_service may not have data yet */ }
// Orders per shop + revenue
// Orders per shop + revenue (scoped)
Dictionary<Guid, int> orderCounts = new();
Dictionary<Guid, decimal> revenues = new();
try
{
await using var orderConn = new NpgsqlConnection(ConnStr("order_service"));
var orderStats = await orderConn.QueryAsync<dynamic>(
"SELECT shop_id, COUNT(*) as cnt, COALESCE(SUM(total_amount), 0) as revenue FROM orders GROUP BY shop_id");
"SELECT shop_id, COUNT(*) as cnt, COALESCE(SUM(total_amount), 0) as revenue FROM orders WHERE shop_id = ANY(@ShopIds) GROUP BY shop_id",
new { ShopIds = shopIdsArray });
foreach (var os in orderStats)
{
orderCounts[(Guid)os.shop_id] = (int)(long)os.cnt;
@@ -477,7 +668,7 @@ public class BffDataController : ControllerBase
}
catch { /* order_service may not have data yet */ }
// Staff per shop (via shop_members join)
// Staff per shop (scoped)
Dictionary<Guid, int> staffCounts = new();
try
{
@@ -487,29 +678,21 @@ public class BffDataController : ControllerBase
FROM shop_members sm
JOIN merchant_staff ms ON sm.staff_id = ms.id
JOIN staff_statuses ss ON ms.status_id = ss.id
WHERE ss.name = 'Active'
GROUP BY sm.shop_id");
WHERE ss.name = 'Active' AND sm.shop_id = ANY(@ShopIds)
GROUP BY sm.shop_id",
new { ShopIds = shopIdsArray });
foreach (var ss in staffStats)
staffCounts[(Guid)ss.shop_id] = (int)(long)ss.cnt;
}
catch { /* merchant_service may not have data yet */ }
// EN: Get all shops and merge stats / VI: Lấy shops rồi merge stats
await using var conn = new NpgsqlConnection(ConnStr("merchant_service"));
var shops = await conn.QueryAsync<dynamic>(
"SELECT id FROM shops WHERE is_deleted = false");
var result = shops.Select(s =>
var result = myShopIds.Select(shopId => new
{
var shopId = (Guid)s.id;
return new
{
shop_id = shopId,
product_count = productCounts.GetValueOrDefault(shopId, 0),
order_count = orderCounts.GetValueOrDefault(shopId, 0),
staff_count = staffCounts.GetValueOrDefault(shopId, 0),
revenue = revenues.GetValueOrDefault(shopId, 0m)
};
shop_id = shopId,
product_count = productCounts.GetValueOrDefault(shopId, 0),
order_count = orderCounts.GetValueOrDefault(shopId, 0),
staff_count = staffCounts.GetValueOrDefault(shopId, 0),
revenue = revenues.GetValueOrDefault(shopId, 0m)
});
return Ok(result);

View File

@@ -8,6 +8,7 @@
<ItemGroup>
<PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0-preview.1.25120.3" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.1" />
<PackageReference Include="Npgsql" Version="9.0.3" />