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()); // 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( @"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 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( @"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 GetStaff() { + var merchantId = await GetCurrentMerchantIdAsync(); + if (merchantId == null) + return Ok(Array.Empty()); + await using var conn = new NpgsqlConnection(ConnStr("merchant_service")); var staff = await conn.QueryAsync( @"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 ═══ /// - /// 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. /// [HttpGet("products")] public async Task GetAllProducts([FromQuery] Guid? shopId = null) { + var merchantId = await GetCurrentMerchantIdAsync(); + if (merchantId == null) + return Ok(Array.Empty()); + + var myShopIds = await GetMyShopIdsAsync(merchantId.Value); + if (!myShopIds.Any()) + return Ok(Array.Empty()); + + // 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()); + + var targetShopIds = shopId.HasValue ? new List { 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(sql, new { ShopId = shopId }); + var products = await conn.QueryAsync( + @"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); } /// - /// 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. /// [HttpGet("categories")] public async Task GetAllCategories([FromQuery] Guid? shopId = null) { + var merchantId = await GetCurrentMerchantIdAsync(); + if (merchantId == null) + return Ok(Array.Empty()); + + var myShopIds = await GetMyShopIdsAsync(merchantId.Value); + if (!myShopIds.Any()) + return Ok(Array.Empty()); + + if (shopId.HasValue && !myShopIds.Contains(shopId.Value)) + return Ok(Array.Empty()); + + var targetShopIds = shopId.HasValue ? new List { 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(sql, new { ShopId = shopId }); + var categories = await conn.QueryAsync( + @"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); } /// - /// 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. /// [HttpPost("products")] public async Task 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 } /// - /// 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. /// [HttpDelete("products/{productId:guid}")] public async Task 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 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(sql, new { ShopId = shopId }); + var merchantId = await GetCurrentMerchantIdAsync(); + if (merchantId == null) + return Ok(Array.Empty()); - // 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()); + + if (shopId.HasValue && !myShopIds.Contains(shopId.Value)) + return Ok(Array.Empty()); + + var targetShopIds = shopId.HasValue ? new List { shopId.Value } : myShopIds; + + await using var conn = new NpgsqlConnection(ConnStr("inventory_service")); + var items = await conn.QueryAsync( + @"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("SELECT id, name FROM products")).ToList(); + var products = (await catConn.QueryAsync( + "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 ═══ /// - /// 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. /// [HttpPost("staff")] public async Task 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( "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( "SELECT id FROM staff_statuses WHERE name = 'Active'"); @@ -344,41 +485,68 @@ public class BffDataController : ControllerBase [HttpGet("orders")] public async Task GetOrders([FromQuery] Guid? shopId = null) { + var merchantId = await GetCurrentMerchantIdAsync(); + if (merchantId == null) + return Ok(Array.Empty()); + + var myShopIds = await GetMyShopIdsAsync(merchantId.Value); + if (!myShopIds.Any()) + return Ok(Array.Empty()); + + if (shopId.HasValue && !myShopIds.Contains(shopId.Value)) + return Ok(Array.Empty()); + + var targetShopIds = shopId.HasValue ? new List { 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(sql, new { ShopId = shopId }); + var orders = await conn.QueryAsync( + @"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 GetWallets() { + var merchantId = await GetCurrentMerchantIdAsync(); + if (merchantId == null) + return Ok(Array.Empty()); + await using var conn = new NpgsqlConnection(ConnStr("wallet_service")); var wallets = await conn.QueryAsync( @"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 GetWalletTransactions([FromQuery] int limit = 50) { + var merchantId = await GetCurrentMerchantIdAsync(); + if (merchantId == null) + return Ok(Array.Empty()); + await using var conn = new NpgsqlConnection(ConnStr("wallet_service")); var txns = await conn.QueryAsync( @"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 GetInventoryTransactions([FromQuery] Guid? shopId = null) { + var merchantId = await GetCurrentMerchantIdAsync(); + if (merchantId == null) + return Ok(Array.Empty()); + + var myShopIds = await GetMyShopIdsAsync(merchantId.Value); + if (!myShopIds.Any()) + return Ok(Array.Empty()); + + if (shopId.HasValue && !myShopIds.Contains(shopId.Value)) + return Ok(Array.Empty()); + + var targetShopIds = shopId.HasValue ? new List { 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(sql, new { ShopId = shopId }); + var txns = await conn.QueryAsync( + @"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) ═══ /// - /// 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. /// [HttpGet("shops/stats")] public async Task 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()); - // Products per shop + var myShopIds = await GetMyShopIdsAsync(merchantId.Value); + if (!myShopIds.Any()) + return Ok(Array.Empty()); + + var shopIdsArray = myShopIds.ToArray(); + + // Products per shop (scoped) Dictionary productCounts = new(); try { await using var catConn = new NpgsqlConnection(ConnStr("catalog_service")); var prodStats = await catConn.QueryAsync( - "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 orderCounts = new(); Dictionary revenues = new(); try { await using var orderConn = new NpgsqlConnection(ConnStr("order_service")); var orderStats = await orderConn.QueryAsync( - "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 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( - "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); diff --git a/apps/web-client-tpos-net/src/WebClientTpos.Server/WebClientTpos.Server.csproj b/apps/web-client-tpos-net/src/WebClientTpos.Server/WebClientTpos.Server.csproj index d89c0221..bdba6648 100644 --- a/apps/web-client-tpos-net/src/WebClientTpos.Server/WebClientTpos.Server.csproj +++ b/apps/web-client-tpos-net/src/WebClientTpos.Server/WebClientTpos.Server.csproj @@ -8,6 +8,7 @@ +