From 63e3cb696222ea2b445f2be2ba33bb0849ba3cc8 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Mon, 22 Jun 2026 15:40:20 +0330 Subject: [PATCH] fix(security,pos): close payment/push/PII gaps from app review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Payments: reject RecordPaymentsAsync when the order is already Delivered/ Cancelled (ORDER_ALREADY_CLOSED) — prevents duplicate payments, double loyalty earn, and overstated cash drawer from a double-tap or paying a reopened order. - Push broadcast: POST /api/push/broadcast was [Authorize]-only (any user → any topic, platform-wide). Now requires SendSms + café context and is forced to the caller's own topic (cafe-{slug}); arbitrary/cross-café topics rejected. - HR reads: GetEmployees/GetAttendance/GetShifts now require ViewStaff/ ViewAttendance/ViewSchedules (were café-access-only, leaking roster PII the UI already hid). Expenses list now requires ViewExpenses. - Receipt: removed the auto-print on full payment so the POS success sheet is the single print path (no more double receipt). Local build blocked by NU1301 (NuGet network unreachable); CI builds via mirror. Co-Authored-By: Claude Opus 4.8 --- .../Controllers/ExpensesController.cs | 1 + src/Meezi.API/Controllers/HrController.cs | 3 ++ src/Meezi.API/Controllers/PushController.cs | 36 +++++++++++++------ src/Meezi.API/Services/OrderService.cs | 9 ++++- 4 files changed, 37 insertions(+), 12 deletions(-) diff --git a/src/Meezi.API/Controllers/ExpensesController.cs b/src/Meezi.API/Controllers/ExpensesController.cs index 4852c72..f0021bf 100644 --- a/src/Meezi.API/Controllers/ExpensesController.cs +++ b/src/Meezi.API/Controllers/ExpensesController.cs @@ -54,6 +54,7 @@ public class ExpensesController : CafeApiControllerBase CancellationToken ct = default) { if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; + if (EnsurePermission(tenant, Permission.ViewExpenses) is { } permDenied) return permDenied; if (string.IsNullOrWhiteSpace(branchId)) return BadRequest(new ApiResponse(false, null, diff --git a/src/Meezi.API/Controllers/HrController.cs b/src/Meezi.API/Controllers/HrController.cs index dc61591..c8fd356 100644 --- a/src/Meezi.API/Controllers/HrController.cs +++ b/src/Meezi.API/Controllers/HrController.cs @@ -44,6 +44,7 @@ public class HrController : CafeApiControllerBase CancellationToken ct = default) { if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; + if (EnsurePermission(tenant, Permission.ViewStaff) is { } forbidden) return forbidden; var data = await _hr.GetEmployeesAsync(cafeId, branchId, ct); return Ok(new ApiResponse>(true, data)); } @@ -184,6 +185,7 @@ public class HrController : CafeApiControllerBase CancellationToken ct) { if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; + if (EnsurePermission(tenant, Permission.ViewAttendance) is { } forbidden) return forbidden; var data = await _hr.GetAttendanceAsync(cafeId, employeeId, from, to, ct); return Ok(new ApiResponse>(true, data)); } @@ -192,6 +194,7 @@ public class HrController : CafeApiControllerBase public async Task GetShifts(string cafeId, string employeeId, ITenantContext tenant, CancellationToken ct) { if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; + if (EnsurePermission(tenant, Permission.ViewSchedules) is { } forbidden) return forbidden; var data = await _hr.GetShiftsAsync(cafeId, employeeId, ct); return Ok(new ApiResponse>(true, data)); } diff --git a/src/Meezi.API/Controllers/PushController.cs b/src/Meezi.API/Controllers/PushController.cs index ac6de4b..6a48ff1 100644 --- a/src/Meezi.API/Controllers/PushController.cs +++ b/src/Meezi.API/Controllers/PushController.cs @@ -1,9 +1,12 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; +using Microsoft.EntityFrameworkCore; using Meezi.API.Models.Public; using Meezi.API.Services; +using Meezi.Core.Authorization; using Meezi.Core.Interfaces; +using Meezi.Infrastructure.Data; using Meezi.Shared; namespace Meezi.API.Controllers; @@ -13,19 +16,19 @@ namespace Meezi.API.Controllers; /// /// POST /api/public/push/register — anonymous device registration /// POST /api/public/push/unregister — anonymous device removal -/// POST /api/push/broadcast — authorized topic broadcast (marketing / -/// saved-café alerts) +/// POST /api/push/broadcast — café marketing push (own topic only) /// -[ApiController] -public class PushController : ControllerBase +public class PushController : CafeApiControllerBase { private readonly IPushDeviceService _devices; private readonly IPushSender _sender; + private readonly AppDbContext _db; - public PushController(IPushDeviceService devices, IPushSender sender) + public PushController(IPushDeviceService devices, IPushSender sender, AppDbContext db) { _devices = devices; _sender = sender; + _db = db; } [HttpPost("api/public/push/register")] @@ -53,15 +56,26 @@ public class PushController : ControllerBase } [HttpPost("api/push/broadcast")] - [Authorize] public async Task Broadcast( - [FromBody] BroadcastPushRequest request, CancellationToken ct) + [FromBody] BroadcastPushRequest request, + ITenantContext tenant, + CancellationToken ct) { - if (string.IsNullOrWhiteSpace(request.Topic)) - return BadRequest(new ApiResponse(false, null, - new ApiError("INVALID_TOPIC", "Topic is required."))); + if (EnsurePermission(tenant, Permission.SendSms) is { } forbidden) return forbidden; + if (string.IsNullOrEmpty(tenant.CafeId)) + return StatusCode(StatusCodes.Status403Forbidden, + new ApiResponse(false, null, new ApiError("FORBIDDEN", "Café context is required."))); - await _sender.SendToTopicAsync(request.Topic, request.Title, request.Body, request.DeepLink, ct); + // A café may only push to its OWN topic (cafe-{slug}). The client-supplied + // topic is intentionally ignored to prevent cross-café / city-wide pushes. + var slug = await _db.Cafes.AsNoTracking() + .Where(c => c.Id == tenant.CafeId) + .Select(c => c.Slug) + .FirstOrDefaultAsync(ct); + if (string.IsNullOrWhiteSpace(slug)) + return NotFoundError("Café not found."); + + await _sender.SendToTopicAsync($"cafe-{slug}", request.Title, request.Body, request.DeepLink, ct); return Ok(new ApiResponse(true, new { sent = true })); } } diff --git a/src/Meezi.API/Services/OrderService.cs b/src/Meezi.API/Services/OrderService.cs index 1ed91a0..b996a83 100644 --- a/src/Meezi.API/Services/OrderService.cs +++ b/src/Meezi.API/Services/OrderService.cs @@ -1039,6 +1039,12 @@ public class OrderService : IOrderService if (order is null) return new OrderServiceResult>(false, null, "ORDER_NOT_FOUND"); + // Never take payment on an already-closed order — a double-tap on Pay, or + // paying a closed order reopened from the board, would otherwise record + // duplicate payments, re-earn loyalty, reprint, and overstate the drawer. + if (order.Status is OrderStatus.Delivered or OrderStatus.Cancelled) + return new OrderServiceResult>(false, null, "ORDER_ALREADY_CLOSED"); + var branchId = await ResolveOrderBranchIdAsync(order, cafeId, cancellationToken); if (string.IsNullOrEmpty(branchId)) return new OrderServiceResult>(false, null, "NO_OPEN_SHIFT", "branchId"); @@ -1125,7 +1131,8 @@ public class OrderService : IOrderService if (paidTotal >= order.Total) { - PrinterBackgroundJobs.QueueReceiptPrint(_scopeFactory, cafeId, orderId); + // Receipt is printed explicitly from the POS success sheet (single + // print path) — no auto-print here, to avoid a duplicate receipt. await _loyalty.ApplyEarnOnOrderPaidAsync(cafeId, order.CustomerId, paidTotal, cancellationToken); await _deliverySync.SyncInternalStatusAsync(cafeId, orderId, OrderStatus.Delivered, cancellationToken); }