feat: custom roles with per-permission matrix for café owners

- Owner can define named custom roles (e.g. Barista, Supervisor) with
  color, description, and a fine-grained permission set (21 permissions
  across 7 categories: admin, menu, staff, customer, reports, ops, kitchen)
- Employee assigned a custom role gets its permissions embedded in the
  JWT at login (customPerms claim) and parsed by TenantMiddleware —
  overrides the static EmployeeRole matrix for all API permission checks
- New endpoints: GET/POST/PATCH/DELETE /api/cafes/{id}/custom-roles and
  PUT /api/cafes/{id}/employees/{id}/custom-role for assignment
- Dashboard Settings → Team & Staff → Custom Roles panel with grouped
  checkbox matrix, group-level toggles, color preset picker, CRUD forms,
  and employee-count display; translations in fa/en/ar
- EF migration adds CustomRoles table + nullable CustomRoleId FK on Employees
- POS slip now shows per-item notes on both thermal print and bill preview

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-21 03:12:43 +03:30
parent 73a5e5183b
commit aebfa825cd
23 changed files with 1126 additions and 20 deletions
@@ -0,0 +1,32 @@
using System.Text.Json;
namespace Meezi.Core.Authorization;
/// <summary>Helpers for serialising/deserialising a custom role's permission set.</summary>
public static class CustomRolePermissions
{
private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web);
/// <summary>Parse the stored JSON array of permission names into a set.</summary>
public static IReadOnlySet<Permission> Parse(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return new HashSet<Permission>();
try
{
var names = JsonSerializer.Deserialize<string[]>(json, JsonOpts) ?? [];
var set = new HashSet<Permission>();
foreach (var name in names)
if (Enum.TryParse<Permission>(name, ignoreCase: true, out var p))
set.Add(p);
return set;
}
catch
{
return new HashSet<Permission>();
}
}
/// <summary>Serialise a permission set to JSON for storage.</summary>
public static string Serialize(IEnumerable<Permission> permissions) =>
JsonSerializer.Serialize(permissions.Select(p => p.ToString()).ToArray(), JsonOpts);
}
+3
View File
@@ -9,6 +9,9 @@ public static class MeeziClaimTypes
public const string BranchId = "branchId";
public const string Actor = "actor";
public const string Phone = "phone";
/// <summary>Comma-separated list of <see cref="Meezi.Core.Authorization.Permission"/> names
/// embedded when the employee has a custom role. Presence overrides the role-based matrix.</summary>
public const string CustomPermissions = "customPerms";
}
public static class MeeziActorKinds
+21
View File
@@ -0,0 +1,21 @@
namespace Meezi.Core.Entities;
/// <summary>
/// A café-defined role template that overrides the standard <see cref="Meezi.Core.Enums.EmployeeRole"/>
/// permission set. The owner creates named roles (e.g. "Barista", "Floor Supervisor") and assigns
/// specific <see cref="Meezi.Core.Authorization.Permission"/> values to each one.
/// When an employee has a <see cref="CustomRoleId"/>, their effective permissions come from here
/// instead of the static <see cref="Meezi.Core.Authorization.RolePermissions"/> matrix.
/// </summary>
public class CustomRole : TenantEntity
{
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
/// <summary>Optional hex color (e.g. "#F59E0B") for badge display in the UI.</summary>
public string? Color { get; set; }
/// <summary>JSON array of <see cref="Meezi.Core.Authorization.Permission"/> enum names.</summary>
public string PermissionsJson { get; set; } = "[]";
public Cafe Cafe { get; set; } = null!;
public ICollection<Employee> Employees { get; set; } = [];
}
+8
View File
@@ -26,6 +26,14 @@ public class Employee : TenantEntity
public ICollection<EmployeeSchedule> Schedules { get; set; } = [];
public ICollection<LeaveRequest> LeaveRequests { get; set; } = [];
/// <summary>
/// Optional custom role defined by the café owner. When set, this role's permission set
/// overrides the standard <see cref="RolePermissions"/> matrix for this employee.
/// The base <see cref="Role"/> enum still controls café-wide vs. branch-scoped behaviour.
/// </summary>
public string? CustomRoleId { get; set; }
public CustomRole? CustomRole { 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; } = [];
}
@@ -1,3 +1,4 @@
using Meezi.Core.Authorization;
using Meezi.Core.Enums;
namespace Meezi.Core.Interfaces;
@@ -14,4 +15,10 @@ public interface ITenantContext
bool IsSystemAdmin { get; }
bool IsAuthenticated { get; }
bool IsCafeOwner => Role == EmployeeRole.Owner;
/// <summary>
/// When non-null the employee has a custom role. These permissions override the static
/// <see cref="RolePermissions"/> matrix; the base <see cref="Role"/> still governs
/// café-wide vs. branch-scoped behaviour.
/// </summary>
IReadOnlySet<Permission>? CustomPermissions { get; }
}