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:
@@ -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(); }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user