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