using Microsoft.AspNetCore.Mvc; using Npgsql; using Dapper; namespace WebClientTpos.Server.Controllers; [ApiController] [Route("api/bff")] public class BffDataController : ControllerBase { // EN: DB host configurable via env var (Docker: "postgres", dev: "localhost") // VI: DB host cấu hình qua env var (Docker: "postgres", dev: "localhost") private static readonly string _dbHost = Environment.GetEnvironmentVariable("BFF_DB_HOST") ?? "localhost"; private static readonly string _dbPort = Environment.GetEnvironmentVariable("BFF_DB_PORT") ?? "5432"; private static readonly string _dbUser = Environment.GetEnvironmentVariable("BFF_DB_USER") ?? "goodgo"; private static readonly string _dbPass = Environment.GetEnvironmentVariable("BFF_DB_PASS") ?? "goodgo_dev_2024"; private static string ConnStr(string db) => $"Host={_dbHost};Port={_dbPort};Database={db};Username={_dbUser};Password={_dbPass}"; [HttpGet("shops")] public async Task GetShops() { 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, s.open_time, s.close_time, s.features_config, bc.name as category, st.name as status 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"); return Ok(shops); } [HttpGet("shops/{shopId:guid}")] public async Task GetShopById(Guid shopId) { 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, s.open_time, s.close_time, bc.name as category, st.name as status 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 }); if (shop == null) return NotFound(new { message = "Shop not found" }); return Ok(shop); } [HttpGet("staff")] public async Task GetStaff() { 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, ms.joined_at, ms.terminated_at, sr.name as role, ss.name as status, s.name as shop_name FROM merchant_staff ms JOIN staff_roles sr ON ms.role_id = sr.id 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"); return Ok(staff); } [HttpGet("shops/{shopId}/products")] public async Task GetProducts(Guid shopId) { await using var conn = new NpgsqlConnection(ConnStr("catalog_service")); var products = await conn.QueryAsync( @"SELECT id, name, price, sku, description, image_url, is_active, attributes->>'category' as category, (attributes->>'duration')::int as duration_minutes FROM products WHERE shop_id = @ShopId AND is_active = true ORDER BY name", new { ShopId = shopId }); return Ok(products); } [HttpGet("shops/{shopId}/categories")] public async Task GetCategories(Guid shopId) { await using var conn = new NpgsqlConnection(ConnStr("catalog_service")); var categories = await conn.QueryAsync( @"SELECT id, name, description, display_order FROM categories WHERE shop_id = @ShopId AND is_active = true ORDER BY display_order", new { ShopId = shopId }); return Ok(categories); } [HttpGet("shops/{shopId}/tables")] public async Task GetTables(Guid shopId) { await using var conn = new NpgsqlConnection(ConnStr("fnb_engine")); var tables = await conn.QueryAsync( @"SELECT t.id, t.table_number, t.capacity, t.zone, CASE t.status_id WHEN 1 THEN 'available' WHEN 2 THEN 'occupied' WHEN 3 THEN 'reserved' WHEN 4 THEN 'cleaning' END as status, s.id as session_id, s.guest_count, s.started_at FROM tables t LEFT JOIN sessions s ON s.table_id = t.id AND s.status = 'Active' WHERE t.shop_id = @ShopId ORDER BY t.table_number", new { ShopId = shopId }); return Ok(tables); } [HttpGet("shops/{shopId}/appointments")] public async Task GetAppointments(Guid shopId) { await using var conn = new NpgsqlConnection(ConnStr("booking_service")); var appointments = await conn.QueryAsync( @"SELECT a.id, a.customer_id, a.staff_id, a.resource_id, a.service_id, a.start_time, a.end_time, a.status, r.name as resource_name FROM appointments a LEFT JOIN resources r ON a.resource_id = r.id WHERE a.shop_id = @ShopId ORDER BY a.start_time", new { ShopId = shopId }); return Ok(appointments); } [HttpGet("shops/{shopId}/resources")] public async Task GetResources(Guid shopId) { await using var conn = new NpgsqlConnection(ConnStr("booking_service")); var resources = await conn.QueryAsync( @"SELECT id, name, resource_type, capacity, is_active FROM resources WHERE shop_id = @ShopId AND is_active = true ORDER BY name", new { ShopId = shopId }); return Ok(resources); } // ═══ ADMIN-LEVEL 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). /// [HttpGet("products")] public async Task GetAllProducts([FromQuery] Guid? shopId = null) { 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 }); 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). /// [HttpGet("categories")] public async Task GetAllCategories([FromQuery] Guid? shopId = null) { 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 }); 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). /// [HttpPost("products")] public async Task CreateProduct([FromBody] CreateProductRequest req) { 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, "Service" => 2, "PreparedFood" => 3, _ => 3 }; await using var conn = new NpgsqlConnection(ConnStr("catalog_service")); await conn.ExecuteAsync( @"INSERT INTO products (id, shop_id, name, description, price, type_id, sku, image_url, is_active, created_at) VALUES (@Id, @ShopId, @Name, @Description, @Price, @TypeId, @Sku, @ImageUrl, true, NOW())", new { Id = id, req.ShopId, req.Name, req.Description, req.Price, TypeId = typeId, req.Sku, req.ImageUrl }); return CreatedAtAction(nameof(GetAllProducts), new { }, new { id }); } /// /// EN: Delete (deactivate) a product. /// VI: Xóa (vô hiệu hóa) sản phẩm. /// [HttpDelete("products/{productId:guid}")] public async Task DeleteProduct(Guid productId) { await using var conn = new NpgsqlConnection(ConnStr("catalog_service")); await conn.ExecuteAsync( "UPDATE products SET is_active = false WHERE id = @Id", new { Id = productId }); return NoContent(); } // ═══ INVENTORY ENDPOINTS ═══ /// /// EN: Get inventory items with product name (cross-DB join via subquery). /// VI: Lấy danh sách tồn kho với tên sản phẩm. /// [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 }); // EN: Enrich with product names from catalog_service // VI: Bổ sung tên sản phẩm từ catalog_service await using var catConn = new NpgsqlConnection(ConnStr("catalog_service")); var products = (await catConn.QueryAsync("SELECT id, name FROM products")).ToList(); var prodMap = products.ToDictionary(p => (Guid)p.id, p => (string)p.name); var result = items.Select(i => new { i.id, i.product_id, i.shop_id, i.quantity, i.reorder_level, i.reserved_quantity, i.updated_at, product_name = prodMap.TryGetValue((Guid)i.product_id, out var name) ? name : "Unknown" }); return Ok(result); } // ═══ MEMBERSHIP/CUSTOMER ENDPOINTS ═══ /// /// EN: Get all members (customers). /// VI: Lấy danh sách thành viên (khách hàng). /// [HttpGet("members")] public async Task GetMembers() { await using var conn = new NpgsqlConnection(ConnStr("membership_service")); var members = await conn.QueryAsync( @"SELECT m.id, m.country_code, m.gender, m.current_exp, m.current_level, m.total_exp_earned, m.created_at, m.preferences, ml.name as level_name FROM members m LEFT JOIN membership_levels ml ON m.current_level = ml.level WHERE m.is_deleted = false ORDER BY m.created_at DESC"); return Ok(members); } // ═══ STAFF CREATE ENDPOINT ═══ /// /// EN: Create a staff member. /// VI: Tạo nhân viên mới. /// [HttpPost("staff")] public async Task CreateStaff([FromBody] CreateStaffRequest req) { 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 var statusId = await conn.QueryFirstOrDefaultAsync( "SELECT id FROM staff_statuses WHERE name = 'Active'"); if (statusId == 0) statusId = 1; await conn.ExecuteAsync( @"INSERT INTO merchant_staff (id, merchant_id, employee_code, phone, email, role_id, status_id, joined_at) VALUES (@Id, @MerchantId, @EmployeeCode, @Phone, @Email, @RoleId, @StatusId, NOW())", new { Id = id, req.MerchantId, req.EmployeeCode, req.Phone, req.Email, RoleId = roleId, StatusId = statusId }); return CreatedAtAction(nameof(GetStaff), new { }, new { id }); } // ═══ STAFF ROLES ═══ [HttpGet("staff/roles")] public async Task GetStaffRoles() { await using var conn = new NpgsqlConnection(ConnStr("merchant_service")); var roles = await conn.QueryAsync("SELECT id, name FROM staff_roles ORDER BY id"); return Ok(roles); } // ═══ STAFF SCHEDULES ═══ [HttpGet("staff/schedules")] public async Task GetStaffSchedules([FromQuery] Guid? shopId = null) { await using var conn = new NpgsqlConnection(ConnStr("booking_service")); var sql = @"SELECT id, staff_id, shop_id, day_of_week, start_time, end_time FROM staff_schedules"; if (shopId.HasValue) sql += " WHERE shop_id = @ShopId"; sql += " ORDER BY day_of_week, start_time"; var schedules = await conn.QueryAsync(sql, new { ShopId = shopId }); // EN: Enrich with staff names / VI: Bổ sung tên nhân viên await using var mConn = new NpgsqlConnection(ConnStr("merchant_service")); var staffList = (await mConn.QueryAsync( "SELECT ms.id, ms.employee_code, ms.phone, sr.name as role FROM merchant_staff ms JOIN staff_roles sr ON ms.role_id = sr.id")).ToList(); var staffMap = staffList.ToDictionary(s => (Guid)s.id, s => new { code = (string?)s.employee_code, role = (string)s.role, phone = (string?)s.phone }); var result = schedules.Select(s => new { s.id, s.staff_id, s.shop_id, s.day_of_week, s.start_time, s.end_time, employee_code = staffMap.TryGetValue((Guid)s.staff_id, out var info) ? info.code : null, role = info?.role, phone = info?.phone }); return Ok(result); } // ═══ ORDERS SUMMARY ═══ [HttpGet("orders")] public async Task GetOrders([FromQuery] Guid? shopId = null) { 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 }); return Ok(orders); } // ═══ WALLET/FINANCE ═══ [HttpGet("wallets")] public async Task GetWallets() { 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"); return Ok(wallets); } [HttpGet("wallet/transactions")] public async Task GetWalletTransactions([FromQuery] int limit = 50) { 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 LEFT JOIN wallet_items wi ON wt.reference_id = wi.id ORDER BY wt.created_at DESC LIMIT @Limit", new { Limit = limit }); return Ok(txns); } // ═══ DEVICES ═══ [HttpGet("devices")] public async Task GetDevices() { await using var conn = new NpgsqlConnection(ConnStr("merchant_service")); var devices = await conn.QueryAsync( @"SELECT dt.id, dt.device_token, dt.platform, dt.is_active, dt.created_at, ms.employee_code as staff_code FROM device_tokens dt LEFT JOIN merchant_staff ms ON dt.staff_id = ms.id ORDER BY dt.created_at DESC"); return Ok(devices); } // ═══ PROMOTIONS ═══ [HttpGet("promotions")] public async Task GetPromotions() { await using var conn = new NpgsqlConnection(ConnStr("promotion_service")); var promos = await conn.QueryAsync( @"SELECT c.id, c.name, c.description, c.start_date, c.end_date, c.is_active, c.discount_type, c.discount_value, (SELECT COUNT(*) FROM vouchers v WHERE v.campaign_id = c.id) as voucher_count, (SELECT COUNT(*) FROM redemptions r WHERE r.campaign_id = c.id) as redemption_count FROM campaigns c ORDER BY c.created_at DESC"); return Ok(promos); } // ═══ INVENTORY TRANSACTIONS ═══ [HttpGet("inventory/transactions")] public async Task GetInventoryTransactions([FromQuery] Guid? shopId = null) { 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 }); return Ok(txns); } // ═══ MEMBERSHIP LEVELS ═══ [HttpGet("membership/levels")] public async Task GetMembershipLevels() { await using var conn = new NpgsqlConnection(ConnStr("membership_service")); var levels = await conn.QueryAsync( @"SELECT ld.id, ld.level, ld.name, ld.min_exp, ld.max_exp, (SELECT COUNT(*) FROM members m WHERE m.current_level = ld.level) as member_count FROM level_definitions ld ORDER BY ld.level"); return Ok(levels); } // EN: Request DTOs / VI: DTO yêu cầu public record CreateProductRequest(Guid ShopId, string Name, string? Description, decimal Price, string? Type, string? Sku, string? ImageUrl); public record CreateStaffRequest(Guid MerchantId, string? EmployeeCode, string? Phone, string? Email, string? Role); }