first commit
CI/CD / CI · Admin API (dotnet build) (push) Successful in 41s
CI/CD / CI · Admin Web (tsc) (push) Failing after 5s
CI/CD / CI · Website (tsc) (push) Failing after 4s
CI/CD / CI · Koja (tsc) (push) Failing after 5s
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m13s
CI/CD / CI · Dashboard (tsc) (push) Failing after 2m32s
CI/CD / Deploy · all services (push) Has been skipped

This commit is contained in:
soroush.asadi
2026-05-31 11:06:24 +03:30
parent 51e422272d
commit 345ae0a4b5
69 changed files with 11964 additions and 152 deletions
@@ -0,0 +1,91 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Meezi.API.Models.Audit;
using Meezi.Core.Authorization;
using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Data;
using Meezi.Shared;
namespace Meezi.API.Controllers;
/// <summary>
/// Read-only access to the immutable POS / management audit trail. Gated by
/// <see cref="Permission.ViewReports"/>; branch-scoped sessions only ever see
/// their own branch's entries (enforced by the DB-level branch isolation filter),
/// café-wide owners see everything.
/// </summary>
[Route("api/cafes/{cafeId}/audit-logs")]
public class AuditController : CafeApiControllerBase
{
private const int MaxPageSize = 100;
private readonly AppDbContext _db;
public AuditController(AppDbContext db)
{
_db = db;
}
[HttpGet]
public async Task<IActionResult> List(
string cafeId,
ITenantContext tenant,
CancellationToken ct,
[FromQuery] string? category = null,
[FromQuery] string? action = null,
[FromQuery] string? branchId = null,
[FromQuery] string? entityType = null,
[FromQuery] string? entityId = null,
[FromQuery] DateTime? from = null,
[FromQuery] DateTime? to = null,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 50)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ViewReports) is { } forbidden) return forbidden;
if (page < 1) page = 1;
if (pageSize < 1) pageSize = 50;
if (pageSize > MaxPageSize) pageSize = MaxPageSize;
var query = _db.AuditLogs.AsNoTracking().Where(x => x.CafeId == cafeId);
if (!string.IsNullOrWhiteSpace(category))
query = query.Where(x => x.Category == category);
if (!string.IsNullOrWhiteSpace(action))
query = query.Where(x => x.Action == action);
if (!string.IsNullOrWhiteSpace(branchId))
query = query.Where(x => x.BranchId == branchId);
if (!string.IsNullOrWhiteSpace(entityType))
query = query.Where(x => x.EntityType == entityType);
if (!string.IsNullOrWhiteSpace(entityId))
query = query.Where(x => x.EntityId == entityId);
if (from is { } f)
query = query.Where(x => x.CreatedAt >= f);
if (to is { } t)
query = query.Where(x => x.CreatedAt <= t);
var total = await query.CountAsync(ct);
var items = await query
.OrderByDescending(x => x.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new AuditLogDto(
x.Id,
x.Category,
x.Action,
x.EntityType,
x.EntityId,
x.BranchId,
x.ActorId,
x.ActorName,
x.ActorRole,
x.Summary,
x.DetailsJson,
x.CreatedAt))
.ToListAsync(ct);
return Ok(new PagedApiResponse<AuditLogDto>(true, items, new PagedMeta(total, page, pageSize)));
}
}
@@ -90,6 +90,27 @@ public class AuthController : ControllerBase
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
}
[HttpPost("switch-branch")]
[Authorize]
[ProducesResponseType(typeof(ApiResponse<AuthTokenResponse>), StatusCodes.Status200OK)]
public async Task<IActionResult> SwitchBranch([FromBody] SwitchBranchRequest request, CancellationToken cancellationToken)
{
var userId = User.FindFirstValue(JwtRegisteredClaimNames.Sub)
?? User.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(userId))
return Unauthorized();
var cafeId = User.FindFirstValue(MeeziClaimTypes.CafeId);
if (string.IsNullOrEmpty(cafeId))
return Unauthorized();
var (success, data, code, message) = await _authService.SwitchBranchAsync(userId, cafeId, request.BranchId, cancellationToken);
if (!success)
return ErrorResult(code!, message!);
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
}
[HttpPost("refresh")]
[ProducesResponseType(typeof(ApiResponse<AuthTokenResponse>), StatusCodes.Status200OK)]
public async Task<IActionResult> Refresh([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken)
@@ -178,6 +199,8 @@ public class AuthController : ControllerBase
new ApiResponse<object>(false, null, new ApiError(code, message))),
"NOT_FOUND" => NotFound(new ApiResponse<object>(false, null, new ApiError(code, message))),
"INVALID_OTP" or "INVALID_TOKEN" => Unauthorized(new ApiResponse<object>(false, null, new ApiError(code, message))),
"BRANCH_FORBIDDEN" => StatusCode(StatusCodes.Status403Forbidden,
new ApiResponse<object>(false, null, new ApiError(code, message))),
"ALREADY_REGISTERED" => Conflict(new ApiResponse<object>(false, null, new ApiError(code, message))),
_ => BadRequest(new ApiResponse<object>(false, null, new ApiError(code, message)))
};
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Meezi.Core.Authorization;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Shared;
@@ -27,6 +28,50 @@ public abstract class CafeApiControllerBase : ControllerBase
new ApiError("OWNER_REQUIRED", "Only the cafe owner can perform this action.")));
}
/// <summary>Owner or Manager may act.</summary>
protected IActionResult? EnsureManager(ITenantContext tenant)
{
if (tenant.Role is EmployeeRole.Owner or EmployeeRole.Manager)
return null;
return Forbidden("MANAGER_REQUIRED", "Manager access required.");
}
/// <summary>The employee acting on their own record, or a manager/owner.</summary>
protected IActionResult? EnsureSelfOrManager(string employeeId, ITenantContext tenant)
{
if (tenant.UserId == employeeId)
return null;
return EnsureManager(tenant);
}
/// <summary>Gate by an explicit capability from the role→permission matrix.</summary>
protected IActionResult? EnsurePermission(ITenantContext tenant, Permission permission)
{
if (tenant.Role is { } role && RolePermissions.Has(role, permission))
return null;
return Forbidden("FORBIDDEN", "You do not have permission to perform this action.");
}
/// <summary>
/// Strict branch isolation at the controller boundary: a branch-scoped session
/// may only touch its own branch. Café-wide sessions (Owner) and sessions with
/// no active branch are unrestricted here (DB query filters back this up).
/// </summary>
protected IActionResult? EnsureBranchAccess(string? routeBranchId, ITenantContext tenant)
{
if (tenant.Role is { } role && RolePermissions.IsCafeWide(role))
return null;
if (string.IsNullOrEmpty(tenant.BranchId))
return null;
if (string.IsNullOrEmpty(routeBranchId) || routeBranchId == tenant.BranchId)
return null;
return Forbidden("BRANCH_FORBIDDEN", "You do not have access to this branch.");
}
private ObjectResult Forbidden(string code, string message) =>
StatusCode(StatusCodes.Status403Forbidden,
new ApiResponse<object>(false, null, new ApiError(code, message)));
protected static ApiResponse<object> ValidationError(FluentValidation.Results.ValidationResult validation)
{
var first = validation.Errors.First();
-17
View File
@@ -201,21 +201,4 @@ public class HrController : CafeApiControllerBase
if (data is null) return NotFoundError();
return Ok(new ApiResponse<EmployeeSalaryDto>(true, data));
}
private static IActionResult? EnsureSelfOrManager(string employeeId, ITenantContext tenant)
{
if (tenant.UserId == employeeId) return null;
return EnsureManager(tenant);
}
private static IActionResult? EnsureManager(ITenantContext tenant)
{
if (tenant.Role is EmployeeRole.Owner or EmployeeRole.Manager)
return null;
return new ObjectResult(new ApiResponse<object>(false, null, new ApiError("FORBIDDEN", "Manager access required.")))
{
StatusCode = StatusCodes.Status403Forbidden
};
}
}
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Orders;
using Meezi.API.Services;
using Meezi.Core.Authorization;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Shared;
@@ -13,6 +14,7 @@ namespace Meezi.API.Controllers;
public class OrdersController : CafeApiControllerBase
{
private readonly IOrderService _orderService;
private readonly IAuditLogService _audit;
private readonly IValidator<CreateOrderRequest> _createValidator;
private readonly IValidator<UpdateOrderStatusRequest> _statusValidator;
private readonly IValidator<RecordPaymentsRequest> _paymentsValidator;
@@ -21,6 +23,7 @@ public class OrdersController : CafeApiControllerBase
public OrdersController(
IOrderService orderService,
IAuditLogService audit,
IValidator<CreateOrderRequest> createValidator,
IValidator<UpdateOrderStatusRequest> statusValidator,
IValidator<RecordPaymentsRequest> paymentsValidator,
@@ -28,6 +31,7 @@ public class OrdersController : CafeApiControllerBase
IValidator<UpdateOrderSessionRequest> sessionValidator)
{
_orderService = orderService;
_audit = audit;
_createValidator = createValidator;
_statusValidator = statusValidator;
_paymentsValidator = paymentsValidator;
@@ -131,6 +135,16 @@ public class OrdersController : CafeApiControllerBase
if (!result.Success)
return OrderError(result.ErrorCode!, result.Field);
await _audit.LogAsync(new AuditEntry
{
Category = "Order",
Action = "ItemVoided",
EntityType = "Order",
EntityId = id,
Summary = $"Voided a line item on order #{result.Data!.DisplayNumber}",
Details = new { orderId = id, itemId, displayNumber = result.Data.DisplayNumber }
}, cancellationToken);
return Ok(new ApiResponse<OrderDto>(true, result.Data));
}
@@ -188,6 +202,42 @@ public class OrdersController : CafeApiControllerBase
return Ok(new ApiResponse<OrderDto>(true, data));
}
[HttpPost("{id}/cancel")]
public async Task<IActionResult> CancelOrder(
string cafeId,
string id,
[FromBody] CancelOrderRequest request,
ITenantContext tenant,
CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ProcessOrders) is { } forbidden) return forbidden;
var result = await _orderService.CancelOrderAsync(
cafeId, id, request.Reason, tenant.UserId, cancellationToken);
if (!result.Success)
return OrderError(result.ErrorCode!, result.Field);
await _audit.LogAsync(new AuditEntry
{
Category = "Order",
Action = "OrderCancelled",
EntityType = "Order",
EntityId = id,
Summary = $"Order #{result.Data!.DisplayNumber} cancelled"
+ (string.IsNullOrWhiteSpace(request.Reason) ? "" : $": {request.Reason!.Trim()}"),
Details = new
{
orderId = id,
displayNumber = result.Data.DisplayNumber,
total = result.Data.Total,
reason = request.Reason
}
}, cancellationToken);
return Ok(new ApiResponse<OrderDto>(true, result.Data));
}
[HttpPost("{id}/payments")]
public async Task<IActionResult> RecordPayments(
string cafeId,
@@ -203,6 +253,23 @@ public class OrdersController : CafeApiControllerBase
var result = await _orderService.RecordPaymentsAsync(
cafeId, id, request, tenant.UserId, cancellationToken);
if (!result.Success) return OrderError(result.ErrorCode!, result.Field);
var paidTotal = result.Data!.Sum(p => p.Amount);
await _audit.LogAsync(new AuditEntry
{
Category = "Payment",
Action = "PaymentRecorded",
EntityType = "Order",
EntityId = id,
Summary = $"Recorded payment(s) totalling {paidTotal:0.##} on order",
Details = new
{
orderId = id,
total = paidTotal,
methods = result.Data!.Select(p => new { p.Method, p.Amount })
}
}, cancellationToken);
return Ok(new ApiResponse<IReadOnlyList<PaymentDto>>(true, result.Data));
}
@@ -219,6 +286,10 @@ public class OrdersController : CafeApiControllerBase
false, null, new ApiError(code, "Order not found.", field))),
"ORDER_ALREADY_CLOSED" => BadRequest(new ApiResponse<object>(
false, null, new ApiError(code, "Order is already closed.", field))),
"ORDER_ALREADY_CANCELLED" => BadRequest(new ApiResponse<object>(
false, null, new ApiError(code, "Order is already cancelled.", field))),
"ORDER_HAS_PAYMENTS" => Conflict(new ApiResponse<object>(
false, null, new ApiError(code, "Refund the recorded payments before cancelling this order.", field))),
"ITEM_NOT_FOUND" => NotFound(new ApiResponse<object>(
false, null, new ApiError(code, "Line item not found.", field))),
"ITEM_ALREADY_VOIDED" => BadRequest(new ApiResponse<object>(
@@ -0,0 +1,195 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Meezi.API.Models.Staff;
using Meezi.Core.Authorization;
using Meezi.Core.Entities;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Data;
using Meezi.Shared;
namespace Meezi.API.Controllers;
/// <summary>
/// Manage the per-branch role assignments that drive the active-branch session model.
/// Owner/Manager gated; branch-scoped managers may only touch their own branch.
/// </summary>
[Route("api/cafes/{cafeId}/employees/{employeeId}/branch-roles")]
public class StaffBranchRolesController : CafeApiControllerBase
{
private readonly AppDbContext _db;
private readonly Meezi.API.Services.IAuditLogService _audit;
public StaffBranchRolesController(AppDbContext db, Meezi.API.Services.IAuditLogService audit)
{
_db = db;
_audit = audit;
}
[HttpGet]
public async Task<IActionResult> List(
string cafeId,
string employeeId,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManageStaff) is { } forbidden) return forbidden;
var employeeExists = await _db.Employees
.AnyAsync(e => e.Id == employeeId && e.CafeId == cafeId && e.DeletedAt == null, ct);
if (!employeeExists) return NotFoundError("Employee not found.");
var data = await _db.EmployeeBranchRoles
.Where(r => r.EmployeeId == employeeId && r.CafeId == cafeId && r.DeletedAt == null)
.Join(_db.Branches, r => r.BranchId, b => b.Id, (r, b) => new BranchRoleAssignmentDto(r.Id, b.Id, b.Name, r.Role))
.OrderBy(d => d.BranchName)
.ToListAsync(ct);
return Ok(new ApiResponse<IReadOnlyList<BranchRoleAssignmentDto>>(true, data));
}
[HttpPost]
public async Task<IActionResult> Assign(
string cafeId,
string employeeId,
[FromBody] AssignBranchRoleRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManageStaff) is { } forbidden) return forbidden;
if (EnsureBranchAccess(request.BranchId, tenant) is { } branchDenied) return branchDenied;
if (request.Role == EmployeeRole.Owner)
return BadRequest(Error("INVALID_ROLE", "Owners are café-wide and cannot be assigned to a branch."));
var employee = await _db.Employees
.FirstOrDefaultAsync(e => e.Id == employeeId && e.CafeId == cafeId && e.DeletedAt == null, ct);
if (employee is null) return NotFoundError("Employee not found.");
if (employee.Role == EmployeeRole.Owner)
return BadRequest(Error("INVALID_ROLE", "The café owner cannot hold per-branch roles."));
var branchExists = await _db.Branches
.AnyAsync(b => b.Id == request.BranchId && b.CafeId == cafeId && b.DeletedAt == null, ct);
if (!branchExists) return NotFoundError("Branch not found.");
var existing = await _db.EmployeeBranchRoles
.FirstOrDefaultAsync(r => r.EmployeeId == employeeId && r.BranchId == request.BranchId && r.DeletedAt == null, ct);
if (existing is not null)
return Conflict(new ApiResponse<object>(false, null,
new ApiError("ALREADY_ASSIGNED", "This employee already has a role in this branch. Update it instead.")));
var assignment = new EmployeeBranchRole
{
CafeId = cafeId,
EmployeeId = employeeId,
BranchId = request.BranchId,
Role = request.Role,
};
_db.EmployeeBranchRoles.Add(assignment);
await _db.SaveChangesAsync(ct);
var branchName = await _db.Branches
.Where(b => b.Id == request.BranchId)
.Select(b => b.Name)
.FirstAsync(ct);
await _audit.LogAsync(new Meezi.API.Services.AuditEntry
{
Category = "Staff",
Action = "BranchRoleAssigned",
EntityType = "Employee",
EntityId = employeeId,
BranchId = request.BranchId,
Summary = $"Assigned {request.Role} role in {branchName} to {employee.Name}",
Details = new { employeeId, branchId = request.BranchId, role = request.Role.ToString() }
}, ct);
return Ok(new ApiResponse<BranchRoleAssignmentDto>(true,
new BranchRoleAssignmentDto(assignment.Id, request.BranchId, branchName, request.Role)));
}
[HttpPatch("{assignmentId}")]
public async Task<IActionResult> Update(
string cafeId,
string employeeId,
string assignmentId,
[FromBody] UpdateBranchRoleRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManageStaff) is { } forbidden) return forbidden;
if (request.Role == EmployeeRole.Owner)
return BadRequest(Error("INVALID_ROLE", "Owners are café-wide and cannot be assigned to a branch."));
var assignment = await _db.EmployeeBranchRoles
.FirstOrDefaultAsync(r => r.Id == assignmentId && r.EmployeeId == employeeId
&& r.CafeId == cafeId && r.DeletedAt == null, ct);
if (assignment is null) return NotFoundError("Branch role assignment not found.");
if (EnsureBranchAccess(assignment.BranchId, tenant) is { } branchDenied) return branchDenied;
assignment.Role = request.Role;
await _db.SaveChangesAsync(ct);
var branchName = await _db.Branches
.Where(b => b.Id == assignment.BranchId)
.Select(b => b.Name)
.FirstAsync(ct);
await _audit.LogAsync(new Meezi.API.Services.AuditEntry
{
Category = "Staff",
Action = "BranchRoleUpdated",
EntityType = "Employee",
EntityId = employeeId,
BranchId = assignment.BranchId,
Summary = $"Changed role to {request.Role} in {branchName}",
Details = new { employeeId, branchId = assignment.BranchId, role = request.Role.ToString() }
}, ct);
return Ok(new ApiResponse<BranchRoleAssignmentDto>(true,
new BranchRoleAssignmentDto(assignment.Id, assignment.BranchId, branchName, assignment.Role)));
}
[HttpDelete("{assignmentId}")]
public async Task<IActionResult> Remove(
string cafeId,
string employeeId,
string assignmentId,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsurePermission(tenant, Permission.ManageStaff) is { } forbidden) return forbidden;
var assignment = await _db.EmployeeBranchRoles
.FirstOrDefaultAsync(r => r.Id == assignmentId && r.EmployeeId == employeeId
&& r.CafeId == cafeId && r.DeletedAt == null, ct);
if (assignment is null) return NotFoundError("Branch role assignment not found.");
if (EnsureBranchAccess(assignment.BranchId, tenant) is { } branchDenied) return branchDenied;
assignment.DeletedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
await _audit.LogAsync(new Meezi.API.Services.AuditEntry
{
Category = "Staff",
Action = "BranchRoleRemoved",
EntityType = "Employee",
EntityId = employeeId,
BranchId = assignment.BranchId,
Summary = $"Removed {assignment.Role} branch role",
Details = new { employeeId, branchId = assignment.BranchId, role = assignment.Role.ToString() }
}, ct);
return Ok(new ApiResponse<object>(true, null));
}
private static ApiResponse<object> Error(string code, string message) =>
new(false, null, new ApiError(code, message));
}
@@ -28,6 +28,7 @@ public static class ServiceCollectionExtensions
services.AddMeeziSecurity(configuration);
services.AddInfrastructure(configuration);
services.AddScoped<IAuthService, AuthService>();
services.AddScoped<IAuditLogService, AuditLogService>();
services.AddScoped<IConsumerAuthService, ConsumerAuthService>();
services.AddScoped<IConsumerOrdersService, ConsumerOrdersService>();
services.AddScoped<IKitchenStationService, KitchenStationService>();
@@ -0,0 +1,16 @@
namespace Meezi.API.Models.Audit;
/// <summary>A single audit-trail entry as exposed to the dashboard.</summary>
public record AuditLogDto(
string Id,
string Category,
string Action,
string? EntityType,
string? EntityId,
string? BranchId,
string? ActorId,
string? ActorName,
string? ActorRole,
string Summary,
string? DetailsJson,
DateTime CreatedAt);
+12 -1
View File
@@ -8,6 +8,9 @@ public record RefreshTokenRequest(string RefreshToken);
public record SwitchCafeRequest(string CafeId);
/// <summary>Switch the active branch within the current café. Null = café-wide (Owner only).</summary>
public record SwitchBranchRequest(string? BranchId);
/// <summary>Step 1 of self-registration: send OTP to a new phone number.</summary>
public record RegisterRequest(string Phone, string CafeName);
@@ -17,6 +20,9 @@ public record VerifyRegisterRequest(string Phone, string Code);
/// <summary>One café membership entry returned when user belongs to multiple cafés.</summary>
public record CafeMembershipDto(string CafeId, string CafeName, string Role, string PlanTier);
/// <summary>A branch the signed-in employee may operate as, with their role there.</summary>
public record BranchMembershipDto(string BranchId, string BranchName, string Role);
public record AuthTokenResponse(
string AccessToken,
string RefreshToken,
@@ -28,7 +34,12 @@ public record AuthTokenResponse(
string Language,
string Actor = Meezi.Core.Constants.MeeziActorKinds.Merchant,
string? BranchId = null,
List<CafeMembershipDto>? Memberships = null);
List<CafeMembershipDto>? Memberships = null,
string? BranchName = null,
bool IsCafeWide = false,
List<BranchMembershipDto>? Branches = null,
/// <summary>Effective capabilities for the active role — drives client-side page/action gating.</summary>
List<string>? Permissions = null);
public record SendOtpResponse(bool Sent, int ExpiresInSeconds);
+2
View File
@@ -62,6 +62,8 @@ public record CreateOrderRequest(
public record UpdateOrderStatusRequest(OrderStatus Status);
public record CancelOrderRequest(string? Reason);
public record CreatePaymentRequest(PaymentMethod Method, decimal Amount, string? Reference);
public record RecordPaymentsRequest(
@@ -0,0 +1,16 @@
using Meezi.Core.Enums;
namespace Meezi.API.Models.Staff;
/// <summary>A single per-branch role assignment for an employee.</summary>
public record BranchRoleAssignmentDto(
string Id,
string BranchId,
string BranchName,
EmployeeRole Role);
/// <summary>Assign (or move) an employee into a branch with a specific role.</summary>
public record AssignBranchRoleRequest(string BranchId, EmployeeRole Role);
/// <summary>Change the role an employee holds in an existing branch assignment.</summary>
public record UpdateBranchRoleRequest(EmployeeRole Role);
+80
View File
@@ -0,0 +1,80 @@
using System.Text.Json;
using Meezi.Core.Entities;
using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Data;
using Microsoft.Extensions.DependencyInjection;
namespace Meezi.API.Services;
/// <summary>
/// Persists audit entries on a fresh, isolated <see cref="AppDbContext"/> so the
/// write never participates in (or rolls back with) the caller's transaction, and
/// swallows all failures — auditing must never break the recorded operation.
/// </summary>
public sealed class AuditLogService : IAuditLogService
{
private static readonly JsonSerializerOptions DetailsJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
private readonly ITenantContext _tenant;
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<AuditLogService> _logger;
public AuditLogService(
ITenantContext tenant,
IServiceScopeFactory scopeFactory,
ILogger<AuditLogService> logger)
{
_tenant = tenant;
_scopeFactory = scopeFactory;
_logger = logger;
}
public async Task LogAsync(AuditEntry entry, CancellationToken ct = default)
{
try
{
var cafeId = _tenant.CafeId;
if (string.IsNullOrEmpty(cafeId))
{
_logger.LogWarning(
"Skipping audit log '{Category}/{Action}' — no cafe context.",
entry.Category, entry.Action);
return;
}
var log = new AuditLog
{
CafeId = cafeId,
BranchId = entry.BranchId ?? _tenant.BranchId,
Category = entry.Category,
Action = entry.Action,
EntityType = entry.EntityType,
EntityId = entry.EntityId,
ActorId = _tenant.UserId,
ActorName = entry.ActorName,
ActorRole = _tenant.Role?.ToString(),
Summary = entry.Summary,
DetailsJson = entry.Details is null
? null
: JsonSerializer.Serialize(entry.Details, DetailsJsonOptions)
};
// Fresh scope → fresh DbContext (café-wide, unfiltered) so this write is
// independent of the business operation's change-tracker and transaction.
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.AuditLogs.Add(log);
await db.SaveChangesAsync(ct);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to write audit log '{Category}/{Action}' for entity {EntityType}:{EntityId}.",
entry.Category, entry.Action, entry.EntityType, entry.EntityId);
}
}
}
+150 -10
View File
@@ -1,5 +1,6 @@
using Meezi.API.Models.Auth;
using Meezi.API.Security;
using Meezi.Core.Authorization;
using Meezi.Core.Entities;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
@@ -156,7 +157,7 @@ public class AuthService : IAuthService
.Select(e => new CafeMembershipDto(e.CafeId, e.Cafe!.Name, e.Role.ToString(), e.Cafe.PlanTier.ToString()))
.ToList();
var tokens = await IssueTokensAsync(employee, employee.Cafe, membershipDtos, cancellationToken);
var tokens = await IssueTokensAsync(employee, employee.Cafe, membershipDtos, null, cancellationToken);
return (true, tokens, null, null, null);
}
@@ -187,7 +188,53 @@ public class AuthService : IAuthService
.Select(e => new CafeMembershipDto(e.CafeId, e.Cafe!.Name, e.Role.ToString(), e.Cafe.PlanTier.ToString()))
.ToList();
var tokens = await IssueTokensAsync(targetEmployee, targetEmployee.Cafe, membershipDtos, cancellationToken);
var tokens = await IssueTokensAsync(targetEmployee, targetEmployee.Cafe, membershipDtos, null, cancellationToken);
return (true, tokens, null, null);
}
public async Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> SwitchBranchAsync(
string employeeId, string cafeId, string? targetBranchId,
CancellationToken cancellationToken = default)
{
var employee = await _db.Employees
.Include(e => e.Cafe)
.FirstOrDefaultAsync(e => e.Id == employeeId && e.CafeId == cafeId && e.DeletedAt == null, cancellationToken);
if (employee?.Cafe is null)
return (false, null, "NOT_FOUND", "User not found.");
// null target = café-wide (Owner only)
if (string.IsNullOrWhiteSpace(targetBranchId))
{
if (employee.Role != EmployeeRole.Owner)
return (false, null, "BRANCH_FORBIDDEN", "Only owners can operate café-wide.");
}
else
{
var branchExists = await _db.Branches
.AnyAsync(b => b.Id == targetBranchId && b.CafeId == cafeId && b.DeletedAt == null, cancellationToken);
if (!branchExists)
return (false, null, "NOT_FOUND", "Branch not found.");
if (employee.Role != EmployeeRole.Owner)
{
var assigned = await _db.EmployeeBranchRoles
.AnyAsync(r => r.EmployeeId == employeeId && r.BranchId == targetBranchId && r.DeletedAt == null, cancellationToken);
if (!assigned && employee.BranchId != targetBranchId)
return (false, null, "BRANCH_FORBIDDEN", "You don't have access to this branch.");
}
}
var allMemberships = await _db.Employees
.Include(e => e.Cafe)
.Where(e => e.Phone == employee.Phone && e.DeletedAt == null)
.ToListAsync(cancellationToken);
var membershipDtos = allMemberships
.Where(e => e.Cafe is not null)
.Select(e => new CafeMembershipDto(e.CafeId, e.Cafe!.Name, e.Role.ToString(), e.Cafe.PlanTier.ToString()))
.ToList();
var tokens = await IssueTokensAsync(employee, employee.Cafe, membershipDtos, targetBranchId, cancellationToken);
return (true, tokens, null, null);
}
@@ -218,7 +265,7 @@ public class AuthService : IAuthService
.Select(e => new CafeMembershipDto(e.CafeId, e.Cafe!.Name, e.Role.ToString(), e.Cafe.PlanTier.ToString()))
.ToList();
var tokens = await IssueTokensAsync(employee, employee.Cafe, membershipDtos, cancellationToken);
var tokens = await IssueTokensAsync(employee, employee.Cafe, membershipDtos, payload.ActiveBranchId, cancellationToken);
return (true, tokens, null, null);
}
@@ -341,7 +388,7 @@ public class AuthService : IAuthService
{
new(cafe.Id, cafe.Name, owner.Role.ToString(), cafe.PlanTier.ToString())
};
var tokens = await IssueTokensAsync(owner, cafe, ownerMembership, cancellationToken);
var tokens = await IssueTokensAsync(owner, cafe, ownerMembership, null, cancellationToken);
return (true, tokens, null, null);
}
@@ -360,9 +407,12 @@ public class AuthService : IAuthService
Core.Entities.Employee employee,
Core.Entities.Cafe cafe,
List<CafeMembershipDto>? memberships,
string? requestedBranchId,
CancellationToken cancellationToken)
{
var accessToken = _jwtTokenService.CreateAccessToken(employee, cafe);
var resolution = await ResolveBranchAsync(employee, cafe, requestedBranchId, cancellationToken);
var accessToken = _jwtTokenService.CreateAccessToken(employee, cafe, resolution.EffectiveRole, resolution.ActiveBranchId);
var refreshToken = _jwtTokenService.CreateRefreshToken();
var refreshDays = _configuration.GetValue("Jwt:RefreshTokenExpiryDays", 30);
@@ -371,24 +421,114 @@ public class AuthService : IAuthService
new RefreshTokenPayload(
employee.Id,
cafe.Id,
employee.Role.ToString(),
resolution.EffectiveRole.ToString(),
cafe.PlanTier.ToString(),
cafe.PreferredLanguage,
Meezi.Core.Constants.MeeziActorKinds.Merchant),
Meezi.Core.Constants.MeeziActorKinds.Merchant,
resolution.ActiveBranchId),
TimeSpan.FromDays(refreshDays),
cancellationToken);
var permissions = Meezi.Core.Authorization.RolePermissions
.For(resolution.EffectiveRole)
.Select(p => p.ToString())
.OrderBy(p => p)
.ToList();
return new AuthTokenResponse(
accessToken,
refreshToken,
_jwtTokenService.GetAccessTokenExpiry(),
employee.Id,
cafe.Id,
employee.Role.ToString(),
resolution.EffectiveRole.ToString(),
cafe.PlanTier.ToString(),
cafe.PreferredLanguage,
Meezi.Core.Constants.MeeziActorKinds.Merchant,
employee.BranchId,
memberships);
resolution.ActiveBranchId,
memberships,
resolution.ActiveBranchName,
resolution.IsCafeWide,
resolution.Branches,
permissions);
}
private sealed record BranchResolution(
EmployeeRole EffectiveRole,
string? ActiveBranchId,
string? ActiveBranchName,
bool IsCafeWide,
List<BranchMembershipDto> Branches);
/// <summary>
/// Determine the active branch, the role the employee holds there, and the
/// full list of branches they may operate as. Owners are café-wide by default
/// (null active branch) but may scope to a specific branch. Other staff are
/// resolved from their <see cref="EmployeeBranchRole"/> assignments, falling
/// back to the legacy single <see cref="Employee.BranchId"/> pin.
/// </summary>
private async Task<BranchResolution> ResolveBranchAsync(
Core.Entities.Employee employee,
Core.Entities.Cafe cafe,
string? requestedBranchId,
CancellationToken ct)
{
var cafeBranches = await _db.Branches
.Where(b => b.CafeId == cafe.Id && b.DeletedAt == null && b.IsActive)
.OrderBy(b => b.Name)
.Select(b => new { b.Id, b.Name })
.ToListAsync(ct);
var branchNames = cafeBranches.ToDictionary(b => b.Id, b => b.Name);
// Owner = café-wide. May optionally scope to a branch when requested & valid.
if (employee.Role == EmployeeRole.Owner)
{
var ownerBranches = cafeBranches
.Select(b => new BranchMembershipDto(b.Id, b.Name, EmployeeRole.Owner.ToString()))
.ToList();
if (!string.IsNullOrWhiteSpace(requestedBranchId) && branchNames.TryGetValue(requestedBranchId, out var rname))
return new BranchResolution(EmployeeRole.Owner, requestedBranchId, rname, false, ownerBranches);
return new BranchResolution(EmployeeRole.Owner, null, null, true, ownerBranches);
}
// Non-owner: explicit per-branch role assignments, plus the legacy pin as a fallback.
var assignments = await _db.EmployeeBranchRoles
.Where(r => r.EmployeeId == employee.Id && r.DeletedAt == null)
.Select(r => new { r.BranchId, r.Role })
.ToListAsync(ct);
var membershipMap = new Dictionary<string, EmployeeRole>();
foreach (var a in assignments)
membershipMap[a.BranchId] = a.Role;
if (!string.IsNullOrWhiteSpace(employee.BranchId) && !membershipMap.ContainsKey(employee.BranchId))
membershipMap[employee.BranchId] = employee.Role;
var branches = membershipMap
.Where(kv => branchNames.ContainsKey(kv.Key))
.Select(kv => new BranchMembershipDto(kv.Key, branchNames[kv.Key], kv.Value.ToString()))
.OrderBy(b => b.BranchName)
.ToList();
// 1. Honour an explicit, valid request.
if (!string.IsNullOrWhiteSpace(requestedBranchId)
&& membershipMap.TryGetValue(requestedBranchId, out var reqRole)
&& branchNames.TryGetValue(requestedBranchId, out var reqName))
{
return new BranchResolution(reqRole, requestedBranchId, reqName, false, branches);
}
// 2/3. One or many memberships → default to the first (frontend can switch).
if (branches.Count >= 1)
{
var first = branches[0];
return new BranchResolution(membershipMap[first.BranchId], first.BranchId, first.BranchName, false, branches);
}
// 4. No assignments and no pin → back-compat: café role, no branch claim (isolation off).
return new BranchResolution(employee.Role, null, null, false, branches);
}
}
@@ -0,0 +1,33 @@
namespace Meezi.API.Services;
/// <summary>
/// One sensitive POS / management action to record. Actor and tenant fields are
/// resolved from the current request context when not supplied explicitly.
/// </summary>
public sealed record AuditEntry
{
public required string Category { get; init; }
public required string Action { get; init; }
public required string Summary { get; init; }
public string? EntityType { get; init; }
public string? EntityId { get; init; }
/// <summary>Optional branch override; defaults to the active branch from context.</summary>
public string? BranchId { get; init; }
/// <summary>Optional structured payload — serialized to JSON.</summary>
public object? Details { get; init; }
/// <summary>Optional actor name override (display only).</summary>
public string? ActorName { get; init; }
}
/// <summary>
/// Writes immutable audit-trail entries for sensitive actions. Implementations
/// must never throw into the caller — a failed audit write must not abort the
/// business operation it records.
/// </summary>
public interface IAuditLogService
{
Task LogAsync(AuditEntry entry, CancellationToken ct = default);
}
+8
View File
@@ -20,6 +20,14 @@ public interface IAuthService
string employeeId, string targetCafeId,
CancellationToken cancellationToken = default);
/// <summary>
/// Re-issue a token scoped to a different branch within the current café.
/// <paramref name="targetBranchId"/> null means café-wide (Owner only).
/// </summary>
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> SwitchBranchAsync(
string employeeId, string cafeId, string? targetBranchId,
CancellationToken cancellationToken = default);
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> RefreshAsync(
RefreshTokenRequest request,
CancellationToken cancellationToken = default);
@@ -6,6 +6,14 @@ namespace Meezi.API.Services;
public interface IJwtTokenService
{
string CreateAccessToken(Employee employee, Cafe cafe);
/// <summary>
/// Issue a token scoped to an active branch. The <paramref name="effectiveRole"/>
/// is the role the employee holds in <paramref name="activeBranchId"/> (or their
/// café-wide role when <paramref name="activeBranchId"/> is null).
/// </summary>
string CreateAccessToken(Employee employee, Cafe cafe, EmployeeRole effectiveRole, string? activeBranchId);
string CreateConsumerAccessToken(ConsumerAccount account, string language = "fa");
string CreateRefreshToken();
DateTime GetAccessTokenExpiry();
+8 -4
View File
@@ -3,6 +3,7 @@ using System.Security.Claims;
using System.Text;
using Meezi.Core.Constants;
using Meezi.Core.Entities;
using Meezi.Core.Enums;
using ConsumerAccount = Meezi.Core.Entities.ConsumerAccount;
using Microsoft.IdentityModel.Tokens;
@@ -17,7 +18,10 @@ public class JwtTokenService : IJwtTokenService
_configuration = configuration;
}
public string CreateAccessToken(Employee employee, Cafe cafe)
public string CreateAccessToken(Employee employee, Cafe cafe) =>
CreateAccessToken(employee, cafe, employee.Role, employee.BranchId);
public string CreateAccessToken(Employee employee, Cafe cafe, EmployeeRole effectiveRole, string? activeBranchId)
{
var key = _configuration["Jwt:Key"] ?? throw new InvalidOperationException("Jwt:Key is not configured.");
var issuer = _configuration["Jwt:Issuer"] ?? "meezi";
@@ -28,14 +32,14 @@ public class JwtTokenService : IJwtTokenService
{
new(JwtRegisteredClaimNames.Sub, employee.Id),
new(MeeziClaimTypes.CafeId, cafe.Id),
new(MeeziClaimTypes.Role, employee.Role.ToString()),
new(MeeziClaimTypes.Role, effectiveRole.ToString()),
new(MeeziClaimTypes.PlanTier, cafe.PlanTier.ToString()),
new(MeeziClaimTypes.Language, cafe.PreferredLanguage),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N"))
};
if (!string.IsNullOrEmpty(employee.BranchId))
claims.Add(new Claim(MeeziClaimTypes.BranchId, employee.BranchId));
if (!string.IsNullOrEmpty(activeBranchId))
claims.Add(new Claim(MeeziClaimTypes.BranchId, activeBranchId));
var credentials = new SigningCredentials(
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)),
+53
View File
@@ -55,6 +55,12 @@ public interface IOrderService
string targetTableId,
CancellationToken cancellationToken = default);
Task<OrderDto?> UpdateStatusAsync(string cafeId, string orderId, OrderStatus status, CancellationToken cancellationToken = default);
Task<OrderServiceResult<OrderDto>> CancelOrderAsync(
string cafeId,
string orderId,
string? reason,
string? cancelledByEmployeeId,
CancellationToken cancellationToken = default);
Task<OrderServiceResult<IReadOnlyList<PaymentDto>>> RecordPaymentsAsync(
string cafeId,
string orderId,
@@ -957,6 +963,53 @@ public class OrderService : IOrderService
return await GetOrderAsync(cafeId, orderId, cancellationToken);
}
public async Task<OrderServiceResult<OrderDto>> CancelOrderAsync(
string cafeId,
string orderId,
string? reason,
string? cancelledByEmployeeId,
CancellationToken cancellationToken = default)
{
var order = await _db.Orders
.Include(o => o.Payments)
.FirstOrDefaultAsync(o => o.Id == orderId && o.CafeId == cafeId, cancellationToken);
if (order is null)
return new OrderServiceResult<OrderDto>(false, null, "ORDER_NOT_FOUND");
if (order.Status == OrderStatus.Cancelled)
return new OrderServiceResult<OrderDto>(false, null, "ORDER_ALREADY_CANCELLED");
if (!OpenForPaymentStatuses.Contains(order.Status))
return new OrderServiceResult<OrderDto>(false, null, "ORDER_NOT_OPEN");
// A paid order must be refunded through the payment flow first — cancelling it
// here would silently strip the recorded money. Block and surface the reason.
if (order.Payments.Any(p => p.DeletedAt == null))
return new OrderServiceResult<OrderDto>(false, null, "ORDER_HAS_PAYMENTS");
order.Status = OrderStatus.Cancelled;
order.StatusUpdatedAt = DateTime.UtcNow;
order.CancelledAt = DateTime.UtcNow;
order.CancelReason = string.IsNullOrWhiteSpace(reason) ? null : reason.Trim();
order.CancelledByEmployeeId = cancelledByEmployeeId;
await _db.SaveChangesAsync(cancellationToken);
await _kdsNotifier.NotifyOrderStatusChangedAsync(cafeId, orderId, OrderStatus.Cancelled, cancellationToken);
if (!string.IsNullOrEmpty(order.TableId))
await _kdsNotifier.NotifyTableStatusChangedAsync(cafeId, cancellationToken);
await _deliverySync.SyncInternalStatusAsync(cafeId, orderId, OrderStatus.Cancelled, cancellationToken);
var loaded = await LoadOrderAsync(cafeId, orderId, cancellationToken);
if (loaded is not null)
await _orderNotifications.NotifyOrderStatusChangedAsync(loaded, cancellationToken);
return loaded is null
? new OrderServiceResult<OrderDto>(false, null, "ORDER_NOT_FOUND")
: new OrderServiceResult<OrderDto>(true, MapOrder(loaded));
}
public async Task<OrderServiceResult<IReadOnlyList<PaymentDto>>> RecordPaymentsAsync(
string cafeId,
string orderId,
+2 -1
View File
@@ -9,7 +9,8 @@ public record RefreshTokenPayload(
string Role,
string PlanTier,
string Language,
string Actor = Meezi.Core.Constants.MeeziActorKinds.Merchant);
string Actor = Meezi.Core.Constants.MeeziActorKinds.Merchant,
string? ActiveBranchId = null);
public interface IRefreshTokenStore
{