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
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:
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace Meezi.Core.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// Capabilities a café employee can be granted. These are the single source of
|
||||
/// truth for authorization — controllers check a <see cref="Permission"/> rather
|
||||
/// than hard-coding role names, so the role→capability mapping lives in exactly
|
||||
/// one place (<see cref="RolePermissions"/>).
|
||||
/// </summary>
|
||||
public enum Permission
|
||||
{
|
||||
// Café-level administration (Owner only)
|
||||
ManageCafeSettings,
|
||||
ManageBilling,
|
||||
ManageBranches,
|
||||
|
||||
// Management (Owner + Manager)
|
||||
ManageStaff,
|
||||
ManageMenu,
|
||||
ManageInventory,
|
||||
ManageExpenses,
|
||||
ManageTaxes,
|
||||
ManageCoupons,
|
||||
ManageReservations,
|
||||
ManageTables,
|
||||
ViewReports,
|
||||
ReviewLeave,
|
||||
ManageSalaries,
|
||||
ManagePrintSettings,
|
||||
|
||||
// Front-of-house operations
|
||||
ProcessOrders,
|
||||
HandlePayments,
|
||||
OperateRegister,
|
||||
ManageQueue,
|
||||
|
||||
// Kitchen
|
||||
ViewKitchen,
|
||||
|
||||
// Delivery
|
||||
HandleDelivery,
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using Meezi.Core.Enums;
|
||||
|
||||
namespace Meezi.Core.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// The authoritative role→capability matrix. Change what a role can do here and
|
||||
/// every controller that calls <c>EnsurePermission</c> updates automatically.
|
||||
/// </summary>
|
||||
public static class RolePermissions
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<EmployeeRole, HashSet<Permission>> Matrix =
|
||||
new Dictionary<EmployeeRole, HashSet<Permission>>
|
||||
{
|
||||
[EmployeeRole.Owner] = AllPermissions(),
|
||||
|
||||
[EmployeeRole.Manager] = new()
|
||||
{
|
||||
Permission.ManageStaff,
|
||||
Permission.ManageMenu,
|
||||
Permission.ManageInventory,
|
||||
Permission.ManageExpenses,
|
||||
Permission.ManageTaxes,
|
||||
Permission.ManageCoupons,
|
||||
Permission.ManageReservations,
|
||||
Permission.ManageTables,
|
||||
Permission.ViewReports,
|
||||
Permission.ReviewLeave,
|
||||
Permission.ManageSalaries,
|
||||
Permission.ManagePrintSettings,
|
||||
Permission.ProcessOrders,
|
||||
Permission.HandlePayments,
|
||||
Permission.OperateRegister,
|
||||
Permission.ManageQueue,
|
||||
Permission.ViewKitchen,
|
||||
Permission.HandleDelivery,
|
||||
},
|
||||
|
||||
[EmployeeRole.Cashier] = new()
|
||||
{
|
||||
Permission.ProcessOrders,
|
||||
Permission.HandlePayments,
|
||||
Permission.OperateRegister,
|
||||
Permission.ManageQueue,
|
||||
Permission.ManageReservations,
|
||||
},
|
||||
|
||||
[EmployeeRole.Waiter] = new()
|
||||
{
|
||||
Permission.ProcessOrders,
|
||||
Permission.ManageReservations,
|
||||
Permission.ManageQueue,
|
||||
},
|
||||
|
||||
[EmployeeRole.Chef] = new()
|
||||
{
|
||||
Permission.ViewKitchen,
|
||||
},
|
||||
|
||||
[EmployeeRole.Delivery] = new()
|
||||
{
|
||||
Permission.HandleDelivery,
|
||||
},
|
||||
};
|
||||
|
||||
public static bool Has(EmployeeRole role, Permission permission) =>
|
||||
Matrix.TryGetValue(role, out var set) && set.Contains(permission);
|
||||
|
||||
public static IReadOnlySet<Permission> For(EmployeeRole role) =>
|
||||
Matrix.TryGetValue(role, out var set) ? set : new HashSet<Permission>();
|
||||
|
||||
/// <summary>True for roles that administer the whole café across all branches.</summary>
|
||||
public static bool IsCafeWide(EmployeeRole role) => role == EmployeeRole.Owner;
|
||||
|
||||
private static HashSet<Permission> AllPermissions() =>
|
||||
new(Enum.GetValues<Permission>());
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace Meezi.Core.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable record of a sensitive POS / management action. Written by
|
||||
/// <c>IAuditLogService</c> and never updated. Branch-scoped so the strict
|
||||
/// branch isolation filter applies (café-wide sessions see all).
|
||||
/// </summary>
|
||||
public class AuditLog : TenantEntity
|
||||
{
|
||||
/// <summary>High-level grouping, e.g. "Order", "Payment", "Register", "Staff".</summary>
|
||||
public string Category { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Specific action, e.g. "OrderCancelled", "ItemVoided", "PaymentRecorded".</summary>
|
||||
public string Action { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>The entity acted upon, e.g. "Order", "Shift".</summary>
|
||||
public string? EntityType { get; set; }
|
||||
|
||||
/// <summary>Id of the affected entity.</summary>
|
||||
public string? EntityId { get; set; }
|
||||
|
||||
public string? BranchId { get; set; }
|
||||
|
||||
/// <summary>Employee who performed the action (null for system/automated).</summary>
|
||||
public string? ActorId { get; set; }
|
||||
public string? ActorName { get; set; }
|
||||
public string? ActorRole { get; set; }
|
||||
|
||||
/// <summary>Human-readable one-line summary (already localized at write time or neutral).</summary>
|
||||
public string Summary { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Optional structured payload (before/after, amounts, reason) as JSON.</summary>
|
||||
public string? DetailsJson { get; set; }
|
||||
}
|
||||
@@ -39,4 +39,7 @@ public class Branch : TenantEntity
|
||||
public ICollection<Table> Tables { get; set; } = [];
|
||||
public ICollection<Order> Orders { get; set; } = [];
|
||||
public ICollection<Employee> Staff { get; set; } = [];
|
||||
|
||||
/// <summary>Per-branch role assignments scoped to this branch.</summary>
|
||||
public ICollection<EmployeeBranchRole> StaffRoles { get; set; } = [];
|
||||
}
|
||||
|
||||
@@ -19,4 +19,7 @@ public class Employee : TenantEntity
|
||||
public ICollection<Attendance> Attendances { get; set; } = [];
|
||||
public ICollection<EmployeeSchedule> Schedules { get; set; } = [];
|
||||
public ICollection<LeaveRequest> LeaveRequests { get; set; } = [];
|
||||
|
||||
/// <summary>Per-branch role assignments (multi-branch staff). Owners are café-wide and may have none.</summary>
|
||||
public ICollection<EmployeeBranchRole> BranchRoles { get; set; } = [];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using Meezi.Core.Enums;
|
||||
|
||||
namespace Meezi.Core.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Per-branch role assignment for an employee. An employee row is scoped to one café
|
||||
/// (a "membership"); this join lets that same employee hold a different
|
||||
/// <see cref="EmployeeRole"/> in each branch they work at.
|
||||
/// Owners remain café-wide via <see cref="Employee.Role"/> and need no rows here.
|
||||
/// </summary>
|
||||
public class EmployeeBranchRole : TenantEntity
|
||||
{
|
||||
public string EmployeeId { get; set; } = string.Empty;
|
||||
public string BranchId { get; set; } = string.Empty;
|
||||
public EmployeeRole Role { get; set; }
|
||||
|
||||
public Employee Employee { get; set; } = null!;
|
||||
public Branch Branch { get; set; } = null!;
|
||||
}
|
||||
@@ -34,6 +34,12 @@ public class Order : TenantEntity
|
||||
/// <summary>JSON snapshot: driver, address, delivery ETA, etc.</summary>
|
||||
public string? DeliveryMetaJson { get; set; }
|
||||
|
||||
/// <summary>Reason captured when the order was cancelled (POS audit / accountability).</summary>
|
||||
public string? CancelReason { get; set; }
|
||||
/// <summary>Employee who cancelled the order (null for system/automated).</summary>
|
||||
public string? CancelledByEmployeeId { get; set; }
|
||||
public DateTime? CancelledAt { get; set; }
|
||||
|
||||
public Cafe Cafe { get; set; } = null!;
|
||||
public Branch? Branch { get; set; }
|
||||
public Table? Table { get; set; }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.ChangeTracking;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
@@ -8,8 +9,22 @@ namespace Meezi.Infrastructure.Data;
|
||||
|
||||
public class AppDbContext : DbContext
|
||||
{
|
||||
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
|
||||
// Strict branch isolation. When an active branch scope is present (a
|
||||
// branch-scoped staff session), every branch-owned entity is filtered to that
|
||||
// branch at the DB layer — independent of, and backing up, controller checks.
|
||||
// Café-wide sessions (Owner / "all branches") and non-HTTP contexts (migrations,
|
||||
// background jobs, seeders) leave the scope empty so nothing is filtered.
|
||||
private readonly string? _branchScopeId;
|
||||
private readonly bool _branchScoped;
|
||||
|
||||
public AppDbContext(DbContextOptions<AppDbContext> options, IBranchContext? branch = null)
|
||||
: base(options)
|
||||
{
|
||||
if (branch is { HasBranch: true })
|
||||
{
|
||||
_branchScopeId = branch.BranchId;
|
||||
_branchScoped = true;
|
||||
}
|
||||
}
|
||||
|
||||
public DbSet<Cafe> Cafes => Set<Cafe>();
|
||||
@@ -17,6 +32,7 @@ public class AppDbContext : DbContext
|
||||
public DbSet<Table> Tables => Set<Table>();
|
||||
public DbSet<TableSection> TableSections => Set<TableSection>();
|
||||
public DbSet<Employee> Employees => Set<Employee>();
|
||||
public DbSet<EmployeeBranchRole> EmployeeBranchRoles => Set<EmployeeBranchRole>();
|
||||
public DbSet<MenuCategory> MenuCategories => Set<MenuCategory>();
|
||||
public DbSet<MenuItem> MenuItems => Set<MenuItem>();
|
||||
public DbSet<BranchMenuItemOverride> BranchMenuItemOverrides => Set<BranchMenuItemOverride>();
|
||||
@@ -63,6 +79,9 @@ public class AppDbContext : DbContext
|
||||
// Push notifications (Pushe)
|
||||
public DbSet<PushDevice> PushDevices => Set<PushDevice>();
|
||||
|
||||
// Immutable audit trail of sensitive POS / management actions.
|
||||
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
@@ -120,7 +139,7 @@ public class AppDbContext : DbContext
|
||||
e.HasIndex(x => new { x.BranchId, x.Name });
|
||||
e.HasIndex(x => x.CafeId);
|
||||
e.HasOne(x => x.Branch).WithMany(b => b.Sections).HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.Cascade);
|
||||
e.HasQueryFilter(x => x.DeletedAt == null);
|
||||
e.HasQueryFilter(x => x.DeletedAt == null && (!_branchScoped || x.BranchId == _branchScopeId));
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Table>(e =>
|
||||
@@ -134,7 +153,7 @@ public class AppDbContext : DbContext
|
||||
e.HasOne(x => x.Cafe).WithMany(c => c.Tables).HasForeignKey(x => x.CafeId).OnDelete(DeleteBehavior.Cascade);
|
||||
e.HasOne(x => x.Branch).WithMany(b => b.Tables).HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.Restrict);
|
||||
e.HasOne(x => x.Section).WithMany(s => s.Tables).HasForeignKey(x => x.SectionId).OnDelete(DeleteBehavior.SetNull);
|
||||
e.HasQueryFilter(x => x.DeletedAt == null);
|
||||
e.HasQueryFilter(x => x.DeletedAt == null && (!_branchScoped || x.BranchId == _branchScopeId));
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Employee>(e =>
|
||||
@@ -149,6 +168,37 @@ public class AppDbContext : DbContext
|
||||
e.HasQueryFilter(x => x.DeletedAt == null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<EmployeeBranchRole>(e =>
|
||||
{
|
||||
e.HasKey(x => x.Id);
|
||||
e.HasIndex(x => new { x.EmployeeId, x.BranchId })
|
||||
.IsUnique()
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
e.HasIndex(x => new { x.CafeId, x.BranchId });
|
||||
e.HasOne(x => x.Employee).WithMany(emp => emp.BranchRoles)
|
||||
.HasForeignKey(x => x.EmployeeId).OnDelete(DeleteBehavior.Cascade);
|
||||
e.HasOne(x => x.Branch).WithMany(b => b.StaffRoles)
|
||||
.HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.Cascade);
|
||||
e.HasQueryFilter(x => x.DeletedAt == null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<AuditLog>(e =>
|
||||
{
|
||||
e.HasKey(x => x.Id);
|
||||
e.Property(x => x.Category).HasMaxLength(64).IsRequired();
|
||||
e.Property(x => x.Action).HasMaxLength(96).IsRequired();
|
||||
e.Property(x => x.EntityType).HasMaxLength(64);
|
||||
e.Property(x => x.EntityId).HasMaxLength(64);
|
||||
e.Property(x => x.ActorName).HasMaxLength(160);
|
||||
e.Property(x => x.ActorRole).HasMaxLength(32);
|
||||
e.Property(x => x.Summary).HasMaxLength(500).IsRequired();
|
||||
e.HasIndex(x => new { x.CafeId, x.Category });
|
||||
e.HasIndex(x => new { x.CafeId, x.BranchId });
|
||||
e.HasIndex(x => new { x.CafeId, x.CreatedAt });
|
||||
e.HasOne<Cafe>().WithMany().HasForeignKey(x => x.CafeId).OnDelete(DeleteBehavior.Cascade);
|
||||
e.HasQueryFilter(x => x.DeletedAt == null && (!_branchScoped || x.BranchId == _branchScopeId));
|
||||
});
|
||||
|
||||
modelBuilder.Entity<MenuCategory>(e =>
|
||||
{
|
||||
e.HasKey(x => x.Id);
|
||||
@@ -180,7 +230,7 @@ public class AppDbContext : DbContext
|
||||
e.HasIndex(x => x.CafeId);
|
||||
e.HasOne(x => x.Branch).WithMany().HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.Cascade);
|
||||
e.HasOne(x => x.MenuItem).WithMany(m => m.BranchOverrides).HasForeignKey(x => x.MenuItemId).OnDelete(DeleteBehavior.Cascade);
|
||||
e.HasQueryFilter(x => x.DeletedAt == null);
|
||||
e.HasQueryFilter(x => x.DeletedAt == null && (!_branchScoped || x.BranchId == _branchScopeId));
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Order>(e =>
|
||||
@@ -204,7 +254,7 @@ public class AppDbContext : DbContext
|
||||
e.HasOne(x => x.Customer).WithMany(c => c.Orders).HasForeignKey(x => x.CustomerId).OnDelete(DeleteBehavior.SetNull);
|
||||
e.HasOne(x => x.Employee).WithMany(emp => emp.Orders).HasForeignKey(x => x.EmployeeId).OnDelete(DeleteBehavior.SetNull);
|
||||
e.HasOne(x => x.Coupon).WithMany(c => c.Orders).HasForeignKey(x => x.CouponId).OnDelete(DeleteBehavior.SetNull);
|
||||
e.HasQueryFilter(x => x.DeletedAt == null);
|
||||
e.HasQueryFilter(x => x.DeletedAt == null && (!_branchScoped || x.BranchId == _branchScopeId));
|
||||
});
|
||||
|
||||
modelBuilder.Entity<OrderItem>(e =>
|
||||
@@ -287,7 +337,7 @@ public class AppDbContext : DbContext
|
||||
e.HasOne(x => x.Branch).WithMany().HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.Cascade);
|
||||
e.HasOne(x => x.OpenedBy).WithMany().HasForeignKey(x => x.OpenedByUserId).OnDelete(DeleteBehavior.Restrict);
|
||||
e.HasOne(x => x.ClosedBy).WithMany().HasForeignKey(x => x.ClosedByUserId).OnDelete(DeleteBehavior.SetNull);
|
||||
e.HasQueryFilter(x => x.DeletedAt == null);
|
||||
e.HasQueryFilter(x => x.DeletedAt == null && (!_branchScoped || x.BranchId == _branchScopeId));
|
||||
});
|
||||
|
||||
modelBuilder.Entity<CashTransaction>(e =>
|
||||
@@ -298,7 +348,7 @@ public class AppDbContext : DbContext
|
||||
e.HasIndex(x => new { x.CafeId, x.BranchId });
|
||||
e.HasOne(x => x.Shift).WithMany(s => s.Transactions).HasForeignKey(x => x.ShiftId).OnDelete(DeleteBehavior.Cascade);
|
||||
e.HasOne(x => x.Branch).WithMany().HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.SetNull);
|
||||
e.HasQueryFilter(x => x.DeletedAt == null);
|
||||
e.HasQueryFilter(x => x.DeletedAt == null && (!_branchScoped || x.BranchId == _branchScopeId));
|
||||
});
|
||||
|
||||
modelBuilder.Entity<LeaveRequest>(e =>
|
||||
@@ -353,7 +403,7 @@ public class AppDbContext : DbContext
|
||||
e.HasIndex(x => new { x.CafeId, x.SortOrder });
|
||||
e.HasOne(x => x.Cafe).WithMany().HasForeignKey(x => x.CafeId).OnDelete(DeleteBehavior.Cascade);
|
||||
e.HasOne(x => x.Branch).WithMany().HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.SetNull);
|
||||
e.HasQueryFilter(x => x.DeletedAt == null);
|
||||
e.HasQueryFilter(x => x.DeletedAt == null && (!_branchScoped || x.BranchId == _branchScopeId));
|
||||
});
|
||||
|
||||
modelBuilder.Entity<SubscriptionPayment>(e =>
|
||||
@@ -414,7 +464,7 @@ public class AppDbContext : DbContext
|
||||
e.HasOne(x => x.Cafe).WithMany().HasForeignKey(x => x.CafeId).OnDelete(DeleteBehavior.Cascade);
|
||||
e.HasOne(x => x.Branch).WithMany().HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.SetNull);
|
||||
e.HasOne(x => x.Order).WithMany().HasForeignKey(x => x.OrderId).OnDelete(DeleteBehavior.SetNull);
|
||||
e.HasQueryFilter(x => x.DeletedAt == null);
|
||||
e.HasQueryFilter(x => x.DeletedAt == null && (!_branchScoped || x.BranchId == _branchScopeId));
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Expense>(e =>
|
||||
@@ -426,7 +476,7 @@ public class AppDbContext : DbContext
|
||||
e.HasIndex(x => new { x.CafeId, x.BranchId, x.CreatedAt });
|
||||
e.HasOne(x => x.Branch).WithMany().HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.Cascade);
|
||||
e.HasOne(x => x.Shift).WithMany().HasForeignKey(x => x.ShiftId).OnDelete(DeleteBehavior.SetNull);
|
||||
e.HasQueryFilter(x => x.DeletedAt == null);
|
||||
e.HasQueryFilter(x => x.DeletedAt == null && (!_branchScoped || x.BranchId == _branchScopeId));
|
||||
});
|
||||
|
||||
modelBuilder.Entity<DailyReport>(e =>
|
||||
@@ -457,7 +507,7 @@ public class AppDbContext : DbContext
|
||||
.HasConversion(topProductsConverter, topProductsComparer)
|
||||
.HasColumnType("jsonb");
|
||||
e.HasOne(x => x.Branch).WithMany().HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.Cascade);
|
||||
e.HasQueryFilter(x => x.DeletedAt == null);
|
||||
e.HasQueryFilter(x => x.DeletedAt == null && (!_branchScoped || x.BranchId == _branchScopeId));
|
||||
});
|
||||
|
||||
modelBuilder.Entity<WebhookLog>(e =>
|
||||
|
||||
+3203
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,83 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Meezi.Infrastructure.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddEmployeeBranchRole : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "EmployeeBranchRoles",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "text", nullable: false),
|
||||
EmployeeId = table.Column<string>(type: "text", nullable: false),
|
||||
BranchId = table.Column<string>(type: "text", nullable: false),
|
||||
Role = table.Column<int>(type: "integer", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
CafeId = table.Column<string>(type: "text", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_EmployeeBranchRoles", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_EmployeeBranchRoles_Branches_BranchId",
|
||||
column: x => x.BranchId,
|
||||
principalTable: "Branches",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_EmployeeBranchRoles_Employees_EmployeeId",
|
||||
column: x => x.EmployeeId,
|
||||
principalTable: "Employees",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_EmployeeBranchRoles_BranchId",
|
||||
table: "EmployeeBranchRoles",
|
||||
column: "BranchId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_EmployeeBranchRoles_CafeId_BranchId",
|
||||
table: "EmployeeBranchRoles",
|
||||
columns: new[] { "CafeId", "BranchId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_EmployeeBranchRoles_EmployeeId_BranchId",
|
||||
table: "EmployeeBranchRoles",
|
||||
columns: new[] { "EmployeeId", "BranchId" },
|
||||
unique: true,
|
||||
filter: "\"DeletedAt\" IS NULL");
|
||||
|
||||
// Backfill: every existing branch-pinned, non-owner employee gets an
|
||||
// explicit per-branch role row mirroring their current (BranchId, Role).
|
||||
// Owners (Role = 0) and café-wide non-pinned staff (BranchId IS NULL) are
|
||||
// left untouched — they remain café-wide via Employee.Role.
|
||||
migrationBuilder.Sql(@"
|
||||
INSERT INTO ""EmployeeBranchRoles""
|
||||
(""Id"", ""EmployeeId"", ""BranchId"", ""Role"", ""CafeId"", ""CreatedAt"")
|
||||
SELECT replace(gen_random_uuid()::text, '-', ''),
|
||||
e.""Id"", e.""BranchId"", e.""Role"", e.""CafeId"", now()
|
||||
FROM ""Employees"" e
|
||||
WHERE e.""BranchId"" IS NOT NULL
|
||||
AND e.""DeletedAt"" IS NULL
|
||||
AND e.""Role"" <> 0;
|
||||
");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "EmployeeBranchRoles");
|
||||
}
|
||||
}
|
||||
}
|
||||
+3278
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,67 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Meezi.Infrastructure.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAuditLog : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AuditLogs",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "text", nullable: false),
|
||||
Category = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
Action = table.Column<string>(type: "character varying(96)", maxLength: 96, nullable: false),
|
||||
EntityType = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
|
||||
EntityId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
|
||||
BranchId = table.Column<string>(type: "text", nullable: true),
|
||||
ActorId = table.Column<string>(type: "text", nullable: true),
|
||||
ActorName = table.Column<string>(type: "character varying(160)", maxLength: 160, nullable: true),
|
||||
ActorRole = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
|
||||
Summary = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
|
||||
DetailsJson = table.Column<string>(type: "text", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
CafeId = table.Column<string>(type: "text", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AuditLogs", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_AuditLogs_Cafes_CafeId",
|
||||
column: x => x.CafeId,
|
||||
principalTable: "Cafes",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AuditLogs_CafeId_BranchId",
|
||||
table: "AuditLogs",
|
||||
columns: new[] { "CafeId", "BranchId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AuditLogs_CafeId_Category",
|
||||
table: "AuditLogs",
|
||||
columns: new[] { "CafeId", "Category" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AuditLogs_CafeId_CreatedAt",
|
||||
table: "AuditLogs",
|
||||
columns: new[] { "CafeId", "CreatedAt" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "AuditLogs");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+3287
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Meezi.Infrastructure.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddOrderCancellationFields : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "CancelReason",
|
||||
table: "Orders",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "CancelledAt",
|
||||
table: "Orders",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "CancelledByEmployeeId",
|
||||
table: "Orders",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CancelReason",
|
||||
table: "Orders");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CancelledAt",
|
||||
table: "Orders");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CancelledByEmployeeId",
|
||||
table: "Orders");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,72 @@ namespace Meezi.Infrastructure.Data.Migrations
|
||||
b.ToTable("Attendances");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Meezi.Core.Entities.AuditLog", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Action")
|
||||
.IsRequired()
|
||||
.HasMaxLength(96)
|
||||
.HasColumnType("character varying(96)");
|
||||
|
||||
b.Property<string>("ActorId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ActorName")
|
||||
.HasMaxLength(160)
|
||||
.HasColumnType("character varying(160)");
|
||||
|
||||
b.Property<string>("ActorRole")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("BranchId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("CafeId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("DetailsJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("EntityId")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("EntityType")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("Summary")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CafeId", "BranchId");
|
||||
|
||||
b.HasIndex("CafeId", "Category");
|
||||
|
||||
b.HasIndex("CafeId", "CreatedAt");
|
||||
|
||||
b.ToTable("AuditLogs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Meezi.Core.Entities.Branch", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
@@ -884,6 +950,45 @@ namespace Meezi.Infrastructure.Data.Migrations
|
||||
b.ToTable("Employees");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Meezi.Core.Entities.EmployeeBranchRole", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("BranchId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("CafeId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("EmployeeId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BranchId");
|
||||
|
||||
b.HasIndex("CafeId", "BranchId");
|
||||
|
||||
b.HasIndex("EmployeeId", "BranchId")
|
||||
.IsUnique()
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.ToTable("EmployeeBranchRoles");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Meezi.Core.Entities.EmployeeSalary", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
@@ -1317,6 +1422,15 @@ namespace Meezi.Infrastructure.Data.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("CancelReason")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("CancelledAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("CancelledByEmployeeId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("CouponId")
|
||||
.HasColumnType("text");
|
||||
|
||||
@@ -2424,6 +2538,15 @@ namespace Meezi.Infrastructure.Data.Migrations
|
||||
b.Navigation("Employee");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Meezi.Core.Entities.AuditLog", b =>
|
||||
{
|
||||
b.HasOne("Meezi.Core.Entities.Cafe", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("CafeId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Meezi.Core.Entities.Branch", b =>
|
||||
{
|
||||
b.HasOne("Meezi.Core.Entities.Cafe", "Cafe")
|
||||
@@ -2565,6 +2688,25 @@ namespace Meezi.Infrastructure.Data.Migrations
|
||||
b.Navigation("Cafe");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Meezi.Core.Entities.EmployeeBranchRole", b =>
|
||||
{
|
||||
b.HasOne("Meezi.Core.Entities.Branch", "Branch")
|
||||
.WithMany("StaffRoles")
|
||||
.HasForeignKey("BranchId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Meezi.Core.Entities.Employee", "Employee")
|
||||
.WithMany("BranchRoles")
|
||||
.HasForeignKey("EmployeeId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Branch");
|
||||
|
||||
b.Navigation("Employee");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Meezi.Core.Entities.EmployeeSalary", b =>
|
||||
{
|
||||
b.HasOne("Meezi.Core.Entities.Employee", "Employee")
|
||||
@@ -3012,6 +3154,8 @@ namespace Meezi.Infrastructure.Data.Migrations
|
||||
|
||||
b.Navigation("Staff");
|
||||
|
||||
b.Navigation("StaffRoles");
|
||||
|
||||
b.Navigation("Tables");
|
||||
});
|
||||
|
||||
@@ -3061,6 +3205,8 @@ namespace Meezi.Infrastructure.Data.Migrations
|
||||
{
|
||||
b.Navigation("Attendances");
|
||||
|
||||
b.Navigation("BranchRoles");
|
||||
|
||||
b.Navigation("LeaveRequests");
|
||||
|
||||
b.Navigation("Orders");
|
||||
|
||||
Reference in New Issue
Block a user