fix(security,pos): close payment/push/PII gaps from app review
CI/CD / CI · API (dotnet build + test) (push) Successful in 59s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 33s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m10s
CI/CD / CI · Admin Web (tsc) (push) Successful in 40s
CI/CD / CI · Website (tsc) (push) Successful in 48s
CI/CD / CI · Koja (tsc) (push) Successful in 52s
CI/CD / Deploy · all services (push) Successful in 2m13s

- 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 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-22 15:40:20 +03:30
parent c360fbb068
commit 63e3cb6962
4 changed files with 37 additions and 12 deletions
@@ -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<object>(false, null,
@@ -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<IReadOnlyList<EmployeeSummaryDto>>(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<IReadOnlyList<AttendanceDto>>(true, data));
}
@@ -192,6 +194,7 @@ public class HrController : CafeApiControllerBase
public async Task<IActionResult> 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<IReadOnlyList<ShiftDto>>(true, data));
}
+25 -11
View File
@@ -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)
/// </summary>
[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<IActionResult> Broadcast(
[FromBody] BroadcastPushRequest request, CancellationToken ct)
[FromBody] BroadcastPushRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.Topic))
return BadRequest(new ApiResponse<object>(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<object>(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<object>(true, new { sent = true }));
}
}
+8 -1
View File
@@ -1039,6 +1039,12 @@ public class OrderService : IOrderService
if (order is null)
return new OrderServiceResult<IReadOnlyList<PaymentDto>>(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<IReadOnlyList<PaymentDto>>(false, null, "ORDER_ALREADY_CLOSED");
var branchId = await ResolveOrderBranchIdAsync(order, cafeId, cancellationToken);
if (string.IsNullOrEmpty(branchId))
return new OrderServiceResult<IReadOnlyList<PaymentDto>>(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);
}