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