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); }