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,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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user