diff --git a/src/Meezi.API/Controllers/CafeApiControllerBase.cs b/src/Meezi.API/Controllers/CafeApiControllerBase.cs index 02db159..f42a349 100644 --- a/src/Meezi.API/Controllers/CafeApiControllerBase.cs +++ b/src/Meezi.API/Controllers/CafeApiControllerBase.cs @@ -44,9 +44,14 @@ public abstract class CafeApiControllerBase : ControllerBase return EnsureManager(tenant); } - /// Gate by an explicit capability from the role→permission matrix. + /// Gate by an explicit capability from the role→permission matrix. + /// When the employee has a custom role its permission set is used instead. protected IActionResult? EnsurePermission(ITenantContext tenant, Permission permission) { + if (tenant.CustomPermissions is { } custom) + return custom.Contains(permission) + ? null + : Forbidden("FORBIDDEN", "You do not have permission to perform this action."); if (tenant.Role is { } role && RolePermissions.Has(role, permission)) return null; return Forbidden("FORBIDDEN", "You do not have permission to perform this action."); diff --git a/src/Meezi.API/Controllers/CustomRolesController.cs b/src/Meezi.API/Controllers/CustomRolesController.cs new file mode 100644 index 0000000..b21f9ba --- /dev/null +++ b/src/Meezi.API/Controllers/CustomRolesController.cs @@ -0,0 +1,225 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Meezi.API.Models.CustomRoles; +using Meezi.Core.Authorization; +using Meezi.Core.Entities; +using Meezi.Core.Interfaces; +using Meezi.Infrastructure.Data; +using Meezi.Shared; + +namespace Meezi.API.Controllers; + +[Route("api/cafes/{cafeId}/custom-roles")] +public class CustomRolesController : CafeApiControllerBase +{ + private readonly AppDbContext _db; + + public CustomRolesController(AppDbContext db) + { + _db = db; + } + + [HttpGet] + public async Task List(string cafeId, ITenantContext tenant, CancellationToken ct) + { + if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; + if (EnsureOwner(tenant) is { } forbidden) return forbidden; + + var roles = await _db.CustomRoles + .AsNoTracking() + .Where(r => r.CafeId == cafeId) + .OrderBy(r => r.Name) + .Select(r => new + { + r.Id, + r.Name, + r.Description, + r.Color, + r.PermissionsJson, + EmployeeCount = _db.Employees.Count(e => e.CafeId == cafeId && e.CustomRoleId == r.Id && e.DeletedAt == null), + r.CreatedAt, + }) + .ToListAsync(ct); + + var dtos = roles.Select(r => new CustomRoleDto( + r.Id, + r.Name, + r.Description, + r.Color, + CustomRolePermissions.Parse(r.PermissionsJson).Select(p => p.ToString()).OrderBy(p => p).ToList(), + r.EmployeeCount, + r.CreatedAt)).ToList(); + + return Ok(new ApiResponse>(true, dtos)); + } + + [HttpGet("{id}")] + public async Task Get(string cafeId, string id, ITenantContext tenant, CancellationToken ct) + { + if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; + if (EnsureOwner(tenant) is { } forbidden) return forbidden; + + var r = await _db.CustomRoles.AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == id && x.CafeId == cafeId, ct); + if (r is null) return NotFoundError("Custom role not found."); + + var employeeCount = await _db.Employees + .CountAsync(e => e.CafeId == cafeId && e.CustomRoleId == id && e.DeletedAt == null, ct); + + return Ok(new ApiResponse(true, new CustomRoleDto( + r.Id, r.Name, r.Description, r.Color, + CustomRolePermissions.Parse(r.PermissionsJson).Select(p => p.ToString()).OrderBy(p => p).ToList(), + employeeCount, r.CreatedAt))); + } + + [HttpPost] + public async Task Create( + string cafeId, + [FromBody] CreateCustomRoleRequest request, + ITenantContext tenant, + CancellationToken ct) + { + if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; + if (EnsureOwner(tenant) is { } forbidden) return forbidden; + + var name = request.Name?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(name)) + return BadRequest(new ApiResponse(false, null, new ApiError("VALIDATION_ERROR", "Name is required.", "Name"))); + + var permissions = ParseAndValidatePermissions(request.Permissions); + + var role = new CustomRole + { + CafeId = cafeId, + Name = name, + Description = request.Description?.Trim(), + Color = NormalizeColor(request.Color), + PermissionsJson = CustomRolePermissions.Serialize(permissions), + }; + + _db.CustomRoles.Add(role); + await _db.SaveChangesAsync(ct); + + return CreatedAtAction(nameof(Get), new { cafeId, id = role.Id }, + new ApiResponse(true, ToDto(role, 0))); + } + + [HttpPatch("{id}")] + public async Task Update( + string cafeId, + string id, + [FromBody] UpdateCustomRoleRequest request, + ITenantContext tenant, + CancellationToken ct) + { + if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; + if (EnsureOwner(tenant) is { } forbidden) return forbidden; + + var role = await _db.CustomRoles + .FirstOrDefaultAsync(r => r.Id == id && r.CafeId == cafeId, ct); + if (role is null) return NotFoundError("Custom role not found."); + + if (request.Name is not null) + { + var name = request.Name.Trim(); + if (string.IsNullOrWhiteSpace(name)) + return BadRequest(new ApiResponse(false, null, new ApiError("VALIDATION_ERROR", "Name cannot be empty.", "Name"))); + role.Name = name; + } + + if (request.Description is not null) + role.Description = request.Description.Trim().Length > 0 ? request.Description.Trim() : null; + + if (request.Color is not null) + role.Color = NormalizeColor(request.Color); + + if (request.Permissions is not null) + role.PermissionsJson = CustomRolePermissions.Serialize(ParseAndValidatePermissions(request.Permissions)); + + await _db.SaveChangesAsync(ct); + + var employeeCount = await _db.Employees + .CountAsync(e => e.CafeId == cafeId && e.CustomRoleId == id && e.DeletedAt == null, ct); + + return Ok(new ApiResponse(true, ToDto(role, employeeCount))); + } + + [HttpDelete("{id}")] + public async Task Delete( + string cafeId, + string id, + ITenantContext tenant, + CancellationToken ct) + { + if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; + if (EnsureOwner(tenant) is { } forbidden) return forbidden; + + var role = await _db.CustomRoles + .FirstOrDefaultAsync(r => r.Id == id && r.CafeId == cafeId, ct); + if (role is null) return NotFoundError("Custom role not found."); + + // Unassign employees before deletion so they fall back to their base role permissions. + await _db.Employees + .Where(e => e.CafeId == cafeId && e.CustomRoleId == id) + .ExecuteUpdateAsync(s => s.SetProperty(e => e.CustomRoleId, (string?)null), ct); + + role.DeletedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(ct); + + return Ok(new ApiResponse(true, null)); + } + + // ── Employee custom-role assignment ─────────────────────────────────────── + + [HttpPut("/api/cafes/{cafeId}/employees/{employeeId}/custom-role")] + public async Task AssignToEmployee( + string cafeId, + string employeeId, + [FromBody] AssignCustomRoleRequest request, + ITenantContext tenant, + CancellationToken ct) + { + if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; + if (EnsureOwner(tenant) is { } forbidden) return forbidden; + + 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 (request.CustomRoleId is not null) + { + var roleExists = await _db.CustomRoles + .AnyAsync(r => r.Id == request.CustomRoleId && r.CafeId == cafeId && r.DeletedAt == null, ct); + if (!roleExists) + return NotFoundError("Custom role not found."); + } + + employee.CustomRoleId = request.CustomRoleId; + await _db.SaveChangesAsync(ct); + + return Ok(new ApiResponse(true, null)); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static CustomRoleDto ToDto(CustomRole r, int employeeCount) => new( + r.Id, r.Name, r.Description, r.Color, + CustomRolePermissions.Parse(r.PermissionsJson).Select(p => p.ToString()).OrderBy(p => p).ToList(), + employeeCount, r.CreatedAt); + + private static IEnumerable ParseAndValidatePermissions(IReadOnlyList? names) + { + if (names is null) return []; + return names + .Where(n => Enum.TryParse(n, ignoreCase: true, out _)) + .Select(n => Enum.Parse(n, ignoreCase: true)) + .Distinct(); + } + + private static string? NormalizeColor(string? color) + { + if (string.IsNullOrWhiteSpace(color)) return null; + var c = color.Trim(); + return c.StartsWith('#') ? c : null; + } +} diff --git a/src/Meezi.API/Middleware/TenantMiddleware.cs b/src/Meezi.API/Middleware/TenantMiddleware.cs index 8c28526..e6c7a6a 100644 --- a/src/Meezi.API/Middleware/TenantMiddleware.cs +++ b/src/Meezi.API/Middleware/TenantMiddleware.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Microsoft.EntityFrameworkCore; +using Meezi.Core.Authorization; using Meezi.Core.Constants; using Meezi.Core.Enums; using Meezi.Core.Interfaces; @@ -116,6 +117,16 @@ public class TenantMiddleware else _logger.LogWarning("Ignoring invalid or inactive branchId claim for cafe {CafeId}", cafeId); } + + var customPermsClaim = context.User.FindFirst(MeeziClaimTypes.CustomPermissions)?.Value; + if (!string.IsNullOrEmpty(customPermsClaim)) + { + var set = new HashSet(); + foreach (var name in customPermsClaim.Split(',', StringSplitOptions.RemoveEmptyEntries)) + if (Enum.TryParse(name, ignoreCase: true, out var p)) + set.Add(p); + scopedMerchant.CustomPermissions = set; + } } if (branchContext is BranchContext scopedBranch) diff --git a/src/Meezi.API/Models/CustomRoles/CustomRoleDtos.cs b/src/Meezi.API/Models/CustomRoles/CustomRoleDtos.cs new file mode 100644 index 0000000..3f4a083 --- /dev/null +++ b/src/Meezi.API/Models/CustomRoles/CustomRoleDtos.cs @@ -0,0 +1,24 @@ +namespace Meezi.API.Models.CustomRoles; + +public record CustomRoleDto( + string Id, + string Name, + string? Description, + string? Color, + IReadOnlyList Permissions, + int EmployeeCount, + DateTime CreatedAt); + +public record CreateCustomRoleRequest( + string Name, + string? Description = null, + string? Color = null, + IReadOnlyList? Permissions = null); + +public record UpdateCustomRoleRequest( + string? Name = null, + string? Description = null, + string? Color = null, + IReadOnlyList? Permissions = null); + +public record AssignCustomRoleRequest(string? CustomRoleId); diff --git a/src/Meezi.API/Services/AuthService.cs b/src/Meezi.API/Services/AuthService.cs index db95454..0d7cfdf 100644 --- a/src/Meezi.API/Services/AuthService.cs +++ b/src/Meezi.API/Services/AuthService.cs @@ -558,7 +558,18 @@ public class AuthService : IAuthService { var resolution = await ResolveBranchAsync(employee, cafe, requestedBranchId, cancellationToken); - var accessToken = _jwtTokenService.CreateAccessToken(employee, cafe, resolution.EffectiveRole, resolution.ActiveBranchId); + // Load custom role permissions when the employee has a custom role assigned. + IReadOnlySet? customPerms = null; + if (!string.IsNullOrEmpty(employee.CustomRoleId)) + { + var cr = await _db.CustomRoles + .AsNoTracking() + .FirstOrDefaultAsync(r => r.Id == employee.CustomRoleId && r.CafeId == cafe.Id && r.DeletedAt == null, cancellationToken); + if (cr != null) + customPerms = CustomRolePermissions.Parse(cr.PermissionsJson); + } + + var accessToken = _jwtTokenService.CreateAccessToken(employee, cafe, resolution.EffectiveRole, resolution.ActiveBranchId, customPerms); // On refresh, reuse the caller's refresh token (and slide its TTL below) instead // of minting a new one. A café often runs POS + KDS + queue display at once; if // refresh rotated the token, the first refresh would revoke it and every other @@ -580,8 +591,7 @@ public class AuthService : IAuthService TimeSpan.FromDays(refreshDays), cancellationToken); - var permissions = Meezi.Core.Authorization.RolePermissions - .For(resolution.EffectiveRole) + var permissions = (customPerms as IEnumerable ?? RolePermissions.For(resolution.EffectiveRole)) .Select(p => p.ToString()) .OrderBy(p => p) .ToList(); diff --git a/src/Meezi.API/Services/IJwtTokenService.cs b/src/Meezi.API/Services/IJwtTokenService.cs index 2fc7266..4cb0b9e 100644 --- a/src/Meezi.API/Services/IJwtTokenService.cs +++ b/src/Meezi.API/Services/IJwtTokenService.cs @@ -1,3 +1,4 @@ +using Meezi.Core.Authorization; using Meezi.Core.Entities; using Meezi.Core.Enums; @@ -11,8 +12,15 @@ public interface IJwtTokenService /// Issue a token scoped to an active branch. The /// is the role the employee holds in (or their /// café-wide role when is null). + /// When is non-null the token embeds those + /// permissions as a claim that overrides the role matrix on the server side. /// - string CreateAccessToken(Employee employee, Cafe cafe, EmployeeRole effectiveRole, string? activeBranchId); + string CreateAccessToken( + Employee employee, + Cafe cafe, + EmployeeRole effectiveRole, + string? activeBranchId, + IEnumerable? customPermissions = null); string CreateConsumerAccessToken(ConsumerAccount account, string language = "fa"); string CreateRefreshToken(); diff --git a/src/Meezi.API/Services/JwtTokenService.cs b/src/Meezi.API/Services/JwtTokenService.cs index 3780f39..e196047 100644 --- a/src/Meezi.API/Services/JwtTokenService.cs +++ b/src/Meezi.API/Services/JwtTokenService.cs @@ -1,6 +1,7 @@ using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; +using Meezi.Core.Authorization; using Meezi.Core.Constants; using Meezi.Core.Entities; using Meezi.Core.Enums; @@ -21,7 +22,12 @@ public class JwtTokenService : IJwtTokenService 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) + public string CreateAccessToken( + Employee employee, + Cafe cafe, + EmployeeRole effectiveRole, + string? activeBranchId, + IEnumerable? customPermissions = null) { var key = _configuration["Jwt:Key"] ?? throw new InvalidOperationException("Jwt:Key is not configured."); var issuer = _configuration["Jwt:Issuer"] ?? "meezi"; @@ -41,6 +47,13 @@ public class JwtTokenService : IJwtTokenService if (!string.IsNullOrEmpty(activeBranchId)) claims.Add(new Claim(MeeziClaimTypes.BranchId, activeBranchId)); + if (customPermissions != null) + { + var encoded = string.Join(",", customPermissions.Select(p => p.ToString())); + if (!string.IsNullOrEmpty(encoded)) + claims.Add(new Claim(MeeziClaimTypes.CustomPermissions, encoded)); + } + var credentials = new SigningCredentials( new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)), SecurityAlgorithms.HmacSha256); diff --git a/src/Meezi.Core/Authorization/CustomRolePermissions.cs b/src/Meezi.Core/Authorization/CustomRolePermissions.cs new file mode 100644 index 0000000..180fd19 --- /dev/null +++ b/src/Meezi.Core/Authorization/CustomRolePermissions.cs @@ -0,0 +1,32 @@ +using System.Text.Json; + +namespace Meezi.Core.Authorization; + +/// Helpers for serialising/deserialising a custom role's permission set. +public static class CustomRolePermissions +{ + private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web); + + /// Parse the stored JSON array of permission names into a set. + public static IReadOnlySet Parse(string? json) + { + if (string.IsNullOrWhiteSpace(json)) return new HashSet(); + try + { + var names = JsonSerializer.Deserialize(json, JsonOpts) ?? []; + var set = new HashSet(); + foreach (var name in names) + if (Enum.TryParse(name, ignoreCase: true, out var p)) + set.Add(p); + return set; + } + catch + { + return new HashSet(); + } + } + + /// Serialise a permission set to JSON for storage. + public static string Serialize(IEnumerable permissions) => + JsonSerializer.Serialize(permissions.Select(p => p.ToString()).ToArray(), JsonOpts); +} diff --git a/src/Meezi.Core/Constants/ClaimTypes.cs b/src/Meezi.Core/Constants/ClaimTypes.cs index 68a62cb..e59af39 100644 --- a/src/Meezi.Core/Constants/ClaimTypes.cs +++ b/src/Meezi.Core/Constants/ClaimTypes.cs @@ -9,6 +9,9 @@ public static class MeeziClaimTypes public const string BranchId = "branchId"; public const string Actor = "actor"; public const string Phone = "phone"; + /// Comma-separated list of names + /// embedded when the employee has a custom role. Presence overrides the role-based matrix. + public const string CustomPermissions = "customPerms"; } public static class MeeziActorKinds diff --git a/src/Meezi.Core/Entities/CustomRole.cs b/src/Meezi.Core/Entities/CustomRole.cs new file mode 100644 index 0000000..2a4a7ad --- /dev/null +++ b/src/Meezi.Core/Entities/CustomRole.cs @@ -0,0 +1,21 @@ +namespace Meezi.Core.Entities; + +/// +/// A café-defined role template that overrides the standard +/// permission set. The owner creates named roles (e.g. "Barista", "Floor Supervisor") and assigns +/// specific values to each one. +/// When an employee has a , their effective permissions come from here +/// instead of the static matrix. +/// +public class CustomRole : TenantEntity +{ + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + /// Optional hex color (e.g. "#F59E0B") for badge display in the UI. + public string? Color { get; set; } + /// JSON array of enum names. + public string PermissionsJson { get; set; } = "[]"; + + public Cafe Cafe { get; set; } = null!; + public ICollection Employees { get; set; } = []; +} diff --git a/src/Meezi.Core/Entities/Employee.cs b/src/Meezi.Core/Entities/Employee.cs index 48724f9..4388ad3 100644 --- a/src/Meezi.Core/Entities/Employee.cs +++ b/src/Meezi.Core/Entities/Employee.cs @@ -26,6 +26,14 @@ public class Employee : TenantEntity public ICollection Schedules { get; set; } = []; public ICollection LeaveRequests { get; set; } = []; + /// + /// Optional custom role defined by the café owner. When set, this role's permission set + /// overrides the standard matrix for this employee. + /// The base enum still controls café-wide vs. branch-scoped behaviour. + /// + public string? CustomRoleId { get; set; } + public CustomRole? CustomRole { get; set; } + /// Per-branch role assignments (multi-branch staff). Owners are café-wide and may have none. public ICollection BranchRoles { get; set; } = []; } diff --git a/src/Meezi.Core/Interfaces/ITenantContext.cs b/src/Meezi.Core/Interfaces/ITenantContext.cs index ff17fa7..7c0af8b 100644 --- a/src/Meezi.Core/Interfaces/ITenantContext.cs +++ b/src/Meezi.Core/Interfaces/ITenantContext.cs @@ -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; + /// + /// When non-null the employee has a custom role. These permissions override the static + /// matrix; the base still governs + /// café-wide vs. branch-scoped behaviour. + /// + IReadOnlySet? CustomPermissions { get; } } diff --git a/src/Meezi.Infrastructure/Data/AppDbContext.cs b/src/Meezi.Infrastructure/Data/AppDbContext.cs index a1a6cda..25fd36e 100644 --- a/src/Meezi.Infrastructure/Data/AppDbContext.cs +++ b/src/Meezi.Infrastructure/Data/AppDbContext.cs @@ -32,6 +32,7 @@ public class AppDbContext : DbContext public DbSet Tables => Set
(); public DbSet TableSections => Set(); public DbSet Employees => Set(); + public DbSet CustomRoles => Set(); public DbSet EmployeeBranchRoles => Set(); public DbSet MenuCategories => Set(); public DbSet MenuItems => Set(); @@ -195,6 +196,17 @@ public class AppDbContext : DbContext e.HasQueryFilter(x => x.DeletedAt == null && (!_branchScoped || x.BranchId == _branchScopeId)); }); + modelBuilder.Entity(e => + { + e.HasKey(x => x.Id); + e.Property(x => x.Name).HasMaxLength(100).IsRequired(); + e.Property(x => x.Description).HasMaxLength(500); + e.Property(x => x.Color).HasMaxLength(20); + e.Property(x => x.PermissionsJson).HasMaxLength(2000).IsRequired(); + e.HasOne(x => x.Cafe).WithMany().HasForeignKey(x => x.CafeId).OnDelete(DeleteBehavior.Cascade); + e.HasQueryFilter(x => x.DeletedAt == null); + }); + modelBuilder.Entity(e => { e.HasKey(x => x.Id); @@ -204,6 +216,7 @@ public class AppDbContext : DbContext e.HasIndex(x => x.BranchId); e.HasOne(x => x.Cafe).WithMany(c => c.Employees).HasForeignKey(x => x.CafeId).OnDelete(DeleteBehavior.Cascade); e.HasOne(x => x.Branch).WithMany(b => b.Staff).HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.SetNull); + e.HasOne(x => x.CustomRole).WithMany(r => r.Employees).HasForeignKey(x => x.CustomRoleId).OnDelete(DeleteBehavior.SetNull); e.HasQueryFilter(x => x.DeletedAt == null); }); diff --git a/src/Meezi.Infrastructure/Data/Migrations/20260620100000_AddCustomRoles.cs b/src/Meezi.Infrastructure/Data/Migrations/20260620100000_AddCustomRoles.cs new file mode 100644 index 0000000..00e9386 --- /dev/null +++ b/src/Meezi.Infrastructure/Data/Migrations/20260620100000_AddCustomRoles.cs @@ -0,0 +1,82 @@ +using Meezi.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Meezi.Infrastructure.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260620100000_AddCustomRoles")] + public partial class AddCustomRoles : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "CustomRoles", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + CafeId = table.Column(type: "text", nullable: false), + Name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Description = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + Color = table.Column(type: "character varying(20)", maxLength: 20, nullable: true), + PermissionsJson = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: false, defaultValue: "[]"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true), + }, + constraints: table => + { + table.PrimaryKey("PK_CustomRoles", x => x.Id); + table.ForeignKey( + name: "FK_CustomRoles_Cafes_CafeId", + column: x => x.CafeId, + principalTable: "Cafes", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_CustomRoles_CafeId", + table: "CustomRoles", + column: "CafeId"); + + migrationBuilder.AddColumn( + name: "CustomRoleId", + table: "Employees", + type: "text", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Employees_CustomRoleId", + table: "Employees", + column: "CustomRoleId"); + + migrationBuilder.AddForeignKey( + name: "FK_Employees_CustomRoles_CustomRoleId", + table: "Employees", + column: "CustomRoleId", + principalTable: "CustomRoles", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Employees_CustomRoles_CustomRoleId", + table: "Employees"); + + migrationBuilder.DropIndex( + name: "IX_Employees_CustomRoleId", + table: "Employees"); + + migrationBuilder.DropColumn( + name: "CustomRoleId", + table: "Employees"); + + migrationBuilder.DropTable(name: "CustomRoles"); + } + } +} diff --git a/src/Meezi.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs b/src/Meezi.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs index 4390455..90ccda3 100644 --- a/src/Meezi.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/src/Meezi.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs @@ -928,6 +928,46 @@ namespace Meezi.Infrastructure.Data.Migrations b.ToTable("DemoRequests"); }); + modelBuilder.Entity("Meezi.Core.Entities.CustomRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("CafeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Color") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PermissionsJson") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("CafeId"); + + b.ToTable("CustomRoles"); + }); + modelBuilder.Entity("Meezi.Core.Entities.Employee", b => { b.Property("Id") @@ -946,6 +986,9 @@ namespace Meezi.Infrastructure.Data.Migrations b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); + b.Property("CustomRoleId") + .HasColumnType("text"); + b.Property("DeletedAt") .HasColumnType("timestamp with time zone"); @@ -976,6 +1019,8 @@ namespace Meezi.Infrastructure.Data.Migrations b.HasIndex("BranchId"); + b.HasIndex("CustomRoleId"); + b.HasIndex("CafeId", "Phone") .IsUnique() .HasFilter("\"DeletedAt\" IS NULL"); @@ -2812,6 +2857,17 @@ namespace Meezi.Infrastructure.Data.Migrations b.Navigation("Cafe"); }); + modelBuilder.Entity("Meezi.Core.Entities.CustomRole", b => + { + b.HasOne("Meezi.Core.Entities.Cafe", "Cafe") + .WithMany() + .HasForeignKey("CafeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cafe"); + }); + modelBuilder.Entity("Meezi.Core.Entities.Employee", b => { b.HasOne("Meezi.Core.Entities.Branch", "Branch") @@ -2825,9 +2881,16 @@ namespace Meezi.Infrastructure.Data.Migrations .OnDelete(DeleteBehavior.Cascade) .IsRequired(); + b.HasOne("Meezi.Core.Entities.CustomRole", "CustomRole") + .WithMany("Employees") + .HasForeignKey("CustomRoleId") + .OnDelete(DeleteBehavior.SetNull); + b.Navigation("Branch"); b.Navigation("Cafe"); + + b.Navigation("CustomRole"); }); modelBuilder.Entity("Meezi.Core.Entities.EmployeeBranchRole", b => @@ -3343,6 +3406,11 @@ namespace Meezi.Infrastructure.Data.Migrations b.Navigation("Orders"); }); + modelBuilder.Entity("Meezi.Core.Entities.CustomRole", b => + { + b.Navigation("Employees"); + }); + modelBuilder.Entity("Meezi.Core.Entities.Employee", b => { b.Navigation("Attendances"); diff --git a/src/Meezi.Infrastructure/Data/TenantContext.cs b/src/Meezi.Infrastructure/Data/TenantContext.cs index 6cb097e..f56bac5 100644 --- a/src/Meezi.Infrastructure/Data/TenantContext.cs +++ b/src/Meezi.Infrastructure/Data/TenantContext.cs @@ -1,3 +1,4 @@ +using Meezi.Core.Authorization; using Meezi.Core.Enums; using Meezi.Core.Interfaces; @@ -13,4 +14,5 @@ public class TenantContext : ITenantContext public string? BranchId { get; set; } public bool IsSystemAdmin { get; set; } public bool IsAuthenticated => IsSystemAdmin || !string.IsNullOrEmpty(CafeId); + public IReadOnlySet? CustomPermissions { get; set; } } diff --git a/web/dashboard/messages/ar.json b/web/dashboard/messages/ar.json index c0da562..6a2022a 100644 --- a/web/dashboard/messages/ar.json +++ b/web/dashboard/messages/ar.json @@ -1203,7 +1203,54 @@ "printer": "الطابعة", "printerSettings": "إعدادات الطابعة", "printTest": "صفحة اختبار الطباعة", - "shopDiscover": "اكتشاف و AI" + "shopDiscover": "اكتشاف و AI", + "team": "الفريق والموظفون", + "customRoles": "الأدوار المخصصة" + }, + "customRoles": { + "title": "الأدوار المخصصة", + "subtitle": "حدّد أدواراً بصلاحيات مخصصة لموظفيك", + "newRole": "دور جديد", + "editRole": "تعديل الدور", + "name": "اسم الدور", + "namePlaceholder": "مثلاً: باريستا، مشرف الطابق", + "description": "الوصف (اختياري)", + "descriptionPlaceholder": "وصف مختصر لهذا الدور", + "color": "اللون", + "permissions": "الصلاحيات", + "empty": "لم يتم تعريف أي أدوار مخصصة بعد", + "saveError": "فشل حفظ الدور", + "deleteConfirm": "حذف الدور «{name}»؟ سيعود الموظفون إلى صلاحيات دورهم الأساسي.", + "groupAdmin": "إدارة المقهى", + "groupMenu": "القائمة والمخزون", + "groupStaff": "الموظفون", + "groupCustomer": "العملاء والطاولات", + "groupReports": "التقارير والمالية", + "groupOps": "عمليات الصندوق", + "groupKitchen": "المطبخ والتوصيل", + "perm": { + "ManageCafeSettings": "إعدادات المقهى", + "ManageBilling": "الاشتراك والفواتير", + "ManageBranches": "إدارة الفروع", + "ManageMenu": "إدارة القائمة", + "ManageInventory": "المخزون", + "ManageTaxes": "الضرائب", + "ManagePrintSettings": "إعدادات الطباعة", + "ManageStaff": "إدارة الموظفين", + "ManageSalaries": "الرواتب", + "ReviewLeave": "طلبات الإجازة", + "ManageReservations": "الحجوزات", + "ManageTables": "الطاولات", + "ManageCoupons": "الكوبونات", + "ViewReports": "التقارير", + "ManageExpenses": "المصروفات", + "ProcessOrders": "معالجة الطلبات", + "HandlePayments": "المدفوعات", + "OperateRegister": "الصندوق", + "ManageQueue": "قائمة الانتظار", + "ViewKitchen": "شاشة المطبخ", + "HandleDelivery": "التوصيل" + } }, "appearance": { "paletteSection": "لوحة الألوان", diff --git a/web/dashboard/messages/en.json b/web/dashboard/messages/en.json index 8d57455..68bdd95 100644 --- a/web/dashboard/messages/en.json +++ b/web/dashboard/messages/en.json @@ -1275,7 +1275,54 @@ "printer": "Printer", "printerSettings": "Printer settings", "printTest": "Print test page", - "shopDiscover": "Discover & AI" + "shopDiscover": "Discover & AI", + "team": "Team & Staff", + "customRoles": "Custom Roles" + }, + "customRoles": { + "title": "Custom Roles", + "subtitle": "Define roles with tailored permissions for your staff", + "newRole": "New Role", + "editRole": "Edit Role", + "name": "Role Name", + "namePlaceholder": "e.g. Barista, Floor Supervisor", + "description": "Description (optional)", + "descriptionPlaceholder": "Brief description of this role", + "color": "Color", + "permissions": "Permissions", + "empty": "No custom roles defined yet", + "saveError": "Failed to save role", + "deleteConfirm": "Delete role '{name}'? Employees will revert to their base role permissions.", + "groupAdmin": "Café Administration", + "groupMenu": "Menu & Inventory", + "groupStaff": "Staff", + "groupCustomer": "Customer & Tables", + "groupReports": "Reports & Finance", + "groupOps": "Register Operations", + "groupKitchen": "Kitchen & Delivery", + "perm": { + "ManageCafeSettings": "Café settings", + "ManageBilling": "Billing & subscription", + "ManageBranches": "Manage branches", + "ManageMenu": "Menu management", + "ManageInventory": "Inventory", + "ManageTaxes": "Taxes", + "ManagePrintSettings": "Print settings", + "ManageStaff": "Staff management", + "ManageSalaries": "Salaries", + "ReviewLeave": "Leave requests", + "ManageReservations": "Reservations", + "ManageTables": "Tables", + "ManageCoupons": "Coupons", + "ViewReports": "Reports", + "ManageExpenses": "Expenses", + "ProcessOrders": "Process orders", + "HandlePayments": "Handle payments", + "OperateRegister": "Register", + "ManageQueue": "Queue", + "ViewKitchen": "Kitchen display", + "HandleDelivery": "Delivery" + } }, "appearance": { "paletteSection": "Color palette", diff --git a/web/dashboard/messages/fa.json b/web/dashboard/messages/fa.json index 180b25f..e574af0 100644 --- a/web/dashboard/messages/fa.json +++ b/web/dashboard/messages/fa.json @@ -1276,7 +1276,54 @@ "printer": "پرینتر", "printerSettings": "تنظیمات پرینتر", "printTest": "صفحه تست چاپ", - "shopDiscover": "کشف و AI" + "shopDiscover": "کشف و AI", + "team": "تیم و کارمندان", + "customRoles": "نقش‌های سفارشی" + }, + "customRoles": { + "title": "نقش‌های سفارشی", + "subtitle": "نقش‌هایی با دسترسی دلخواه برای کارمندان تعریف کنید", + "newRole": "نقش جدید", + "editRole": "ویرایش نقش", + "name": "نام نقش", + "namePlaceholder": "مثلاً: باریستا، مسئول طبقه", + "description": "توضیح (اختیاری)", + "descriptionPlaceholder": "توضیح مختصر درباره این نقش", + "color": "رنگ", + "permissions": "دسترسی‌ها", + "empty": "هنوز نقش سفارشی تعریف نشده است", + "saveError": "ذخیره نقش ناموفق بود", + "deleteConfirm": "نقش «{name}» حذف شود؟ این کارمندان به دسترسی پیش‌فرض نقش اصلی خود بازمی‌گردند.", + "groupAdmin": "مدیریت کافه", + "groupMenu": "منو و انبار", + "groupStaff": "پرسنل", + "groupCustomer": "مشتری و میز", + "groupReports": "گزارش و مالی", + "groupOps": "عملیات صندوق", + "groupKitchen": "آشپزخانه و تحویل", + "perm": { + "ManageCafeSettings": "تنظیمات کافه", + "ManageBilling": "اشتراک و پرداخت", + "ManageBranches": "مدیریت شعب", + "ManageMenu": "مدیریت منو", + "ManageInventory": "انبار و موجودی", + "ManageTaxes": "مالیات", + "ManagePrintSettings": "تنظیمات چاپ", + "ManageStaff": "مدیریت کارمندان", + "ManageSalaries": "حقوق و دستمزد", + "ReviewLeave": "بررسی مرخصی", + "ManageReservations": "رزروها", + "ManageTables": "میزها", + "ManageCoupons": "کوپن‌ها", + "ViewReports": "گزارش‌ها", + "ManageExpenses": "هزینه‌ها", + "ProcessOrders": "ثبت سفارش", + "HandlePayments": "پردازش پرداخت", + "OperateRegister": "صندوق", + "ManageQueue": "صف انتظار", + "ViewKitchen": "نمایش آشپزخانه", + "HandleDelivery": "تحویل و پیک" + } }, "appearance": { "paletteSection": "پالت رنگ", diff --git a/web/dashboard/src/components/pos/pos-slip-modal.tsx b/web/dashboard/src/components/pos/pos-slip-modal.tsx index 091d2aa..ea7f987 100644 --- a/web/dashboard/src/components/pos/pos-slip-modal.tsx +++ b/web/dashboard/src/components/pos/pos-slip-modal.tsx @@ -125,6 +125,7 @@ export function PosSlipModal({ name: item.menuItemName, quantity: item.quantity, price: formatCurrency(item.unitPrice * item.quantity, numberLocale), + notes: item.notes, })), totals: { total: formatCurrency(order!.total, numberLocale), @@ -187,13 +188,20 @@ export function PosSlipModal({ )) : activeBillItems.map((item) => ( -
- - {item.menuItemName} × {item.quantity} - - - {formatCurrency(item.unitPrice * item.quantity, numberLocale)} - +
+
+ + {item.menuItemName} × {item.quantity} + + + {formatCurrency(item.unitPrice * item.quantity, numberLocale)} + +
+ {item.notes && ( +
+ {item.notes} +
+ )}
))} diff --git a/web/dashboard/src/components/settings/custom-roles-panel.tsx b/web/dashboard/src/components/settings/custom-roles-panel.tsx new file mode 100644 index 0000000..af443f2 --- /dev/null +++ b/web/dashboard/src/components/settings/custom-roles-panel.tsx @@ -0,0 +1,406 @@ +"use client"; + +import { useState } from "react"; +import { useTranslations } from "next-intl"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Plus, Pencil, Trash2, Users, ShieldCheck } from "lucide-react"; +import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/client"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { useConfirm } from "@/components/providers/confirm-provider"; +import { cn } from "@/lib/utils"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface CustomRoleDto { + id: string; + name: string; + description?: string | null; + color?: string | null; + permissions: string[]; + employeeCount: number; + createdAt: string; +} + +// ─── Permission catalogue ───────────────────────────────────────────────────── + +interface PermGroup { + labelKey: string; + perms: string[]; +} + +const PERM_GROUPS: PermGroup[] = [ + { + labelKey: "customRoles.groupAdmin", + perms: ["ManageCafeSettings", "ManageBilling", "ManageBranches"], + }, + { + labelKey: "customRoles.groupMenu", + perms: ["ManageMenu", "ManageInventory", "ManageTaxes", "ManagePrintSettings"], + }, + { + labelKey: "customRoles.groupStaff", + perms: ["ManageStaff", "ManageSalaries", "ReviewLeave"], + }, + { + labelKey: "customRoles.groupCustomer", + perms: ["ManageReservations", "ManageTables", "ManageCoupons"], + }, + { + labelKey: "customRoles.groupReports", + perms: ["ViewReports", "ManageExpenses"], + }, + { + labelKey: "customRoles.groupOps", + perms: ["ProcessOrders", "HandlePayments", "OperateRegister", "ManageQueue"], + }, + { + labelKey: "customRoles.groupKitchen", + perms: ["ViewKitchen", "HandleDelivery"], + }, +]; + +const PRESET_COLORS = [ + "#6366F1", "#8B5CF6", "#EC4899", "#F59E0B", + "#10B981", "#3B82F6", "#EF4444", "#64748B", +]; + +// ─── Permission checkbox matrix ─────────────────────────────────────────────── + +function PermissionMatrix({ + selected, + onChange, + t, +}: { + selected: Set; + onChange: (next: Set) => void; + t: ReturnType; +}) { + const toggle = (perm: string) => { + const next = new Set(selected); + if (next.has(perm)) next.delete(perm); + else next.add(perm); + onChange(next); + }; + + const toggleGroup = (perms: string[]) => { + const allOn = perms.every((p) => selected.has(p)); + const next = new Set(selected); + if (allOn) perms.forEach((p) => next.delete(p)); + else perms.forEach((p) => next.add(p)); + onChange(next); + }; + + return ( +
+ {PERM_GROUPS.map((group) => { + const allOn = group.perms.every((p) => selected.has(p)); + const someOn = group.perms.some((p) => selected.has(p)); + return ( +
+ +
+ {group.perms.map((perm) => ( + + ))} +
+
+ ); + })} +
+ ); +} + +// ─── Create / Edit form ─────────────────────────────────────────────────────── + +function RoleForm({ + cafeId, + role, + onDone, + t, + tCommon, +}: { + cafeId: string; + role?: CustomRoleDto; + onDone: () => void; + t: ReturnType; + tCommon: ReturnType; +}) { + const qc = useQueryClient(); + const [name, setName] = useState(role?.name ?? ""); + const [description, setDescription] = useState(role?.description ?? ""); + const [color, setColor] = useState(role?.color ?? PRESET_COLORS[0]!); + const [perms, setPerms] = useState>(new Set(role?.permissions ?? [])); + const [error, setError] = useState(null); + + const save = useMutation({ + mutationFn: async () => { + const body = { + name: name.trim(), + description: description.trim() || null, + color, + permissions: Array.from(perms), + }; + if (role) { + return apiPatch(`/api/cafes/${cafeId}/custom-roles/${role.id}`, body); + } + return apiPost(`/api/cafes/${cafeId}/custom-roles`, body); + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["custom-roles", cafeId] }); + onDone(); + }, + onError: () => setError(t("customRoles.saveError")), + }); + + return ( +
+ {/* Name + color */} +
+
+ + setName(e.target.value)} + placeholder={t("customRoles.namePlaceholder")} + maxLength={100} + /> +
+
+ +
+ {PRESET_COLORS.map((c) => ( +
+
+
+ + {/* Description */} +
+ + setDescription(e.target.value)} + placeholder={t("customRoles.descriptionPlaceholder")} + maxLength={200} + /> +
+ + {/* Permissions */} +
+

{t("customRoles.permissions")}

+ +
+ + {error &&

{error}

} + +
+ + +
+
+ ); +} + +// ─── Main panel ─────────────────────────────────────────────────────────────── + +export function CustomRolesPanel({ cafeId }: { cafeId: string }) { + const t = useTranslations("settings"); + const tCommon = useTranslations("common"); + const qc = useQueryClient(); + const confirm = useConfirm(); + const [editing, setEditing] = useState(null); + + const { data: roles = [], isLoading } = useQuery({ + queryKey: ["custom-roles", cafeId], + queryFn: () => apiGet(`/api/cafes/${cafeId}/custom-roles`), + }); + + const deleteRole = useMutation({ + mutationFn: (id: string) => apiDelete(`/api/cafes/${cafeId}/custom-roles/${id}`), + onSuccess: () => qc.invalidateQueries({ queryKey: ["custom-roles", cafeId] }), + }); + + const handleDelete = async (role: CustomRoleDto) => { + const ok = await confirm({ + description: t("customRoles.deleteConfirm", { name: role.name }), + variant: "destructive", + confirmLabel: tCommon("confirm"), + }); + if (!ok) return; + deleteRole.mutate(role.id); + }; + + if (editing !== null) { + return ( + + + + {editing === "new" ? t("customRoles.newRole") : t("customRoles.editRole")} + + + + setEditing(null)} + t={t} + tCommon={tCommon} + /> + + + ); + } + + return ( +
+ + +
+ + + {t("customRoles.title")} + +

+ {t("customRoles.subtitle")} +

+
+ +
+ + {isLoading ? ( +
+ {[0, 1, 2].map((i) => ( +
+ ))} +
+ ) : roles.length === 0 ? ( +

+ {t("customRoles.empty")} +

+ ) : ( +
+ {roles.map((role) => ( +
+ {/* Color dot */} +
+ +
+
+ {role.name} + {role.description && ( + + — {role.description} + + )} +
+ + {/* Permission badges */} +
+ {role.permissions.slice(0, 6).map((p) => ( + + {t(`customRoles.perm.${p}`)} + + ))} + {role.permissions.length > 6 && ( + + +{role.permissions.length - 6} + + )} +
+
+ + {/* Employee count */} +
+ + {role.employeeCount} +
+ + {/* Actions */} +
+ + +
+
+ ))} +
+ )} + + +
+ ); +} diff --git a/web/dashboard/src/components/settings/settings-screen.tsx b/web/dashboard/src/components/settings/settings-screen.tsx index 9069f43..3ebc8b2 100644 --- a/web/dashboard/src/components/settings/settings-screen.tsx +++ b/web/dashboard/src/components/settings/settings-screen.tsx @@ -12,6 +12,7 @@ import { SettingsShopPanel } from "@/components/settings/settings-shop-panel"; import { SettingsTerminalsPanel } from "@/components/settings/settings-terminals-panel"; import { SettingsPrinterPanel } from "@/components/settings/settings-printer-panel"; import { SettingsPrintTestPanel } from "@/components/settings/settings-print-test-panel"; +import { CustomRolesPanel } from "@/components/settings/custom-roles-panel"; import { DEFAULT_SETTINGS_LEAF, groupForLeaf, @@ -25,6 +26,7 @@ const LEAF_PAGE_TITLE: Record = { "shop-discover": "nav.shopDiscover", "printer-config": "nav.printerSettings", "print-test": "nav.printTest", + "team-custom-roles": "nav.customRoles", }; export function SettingsScreen() { @@ -40,7 +42,10 @@ export function SettingsScreen() { const toggleGroup = (group: SettingsGroupId) => { setExpandedGroup((prev) => (prev === group ? prev : group)); - const firstChild = group === "shop" ? "shop-general" : "printer-config"; + const firstChild = + group === "shop" ? "shop-general" : + group === "team" ? "team-custom-roles" : + "printer-config"; if (groupForLeaf(activeLeaf) !== group) { selectLeaf(firstChild); } @@ -98,6 +103,10 @@ export function SettingsScreen() { onOpenPrinterSettings={() => selectLeaf("printer-config")} /> ) : null} + + {activeLeaf === "team-custom-roles" ? ( + + ) : null}
diff --git a/web/dashboard/src/components/settings/settings-types.ts b/web/dashboard/src/components/settings/settings-types.ts index 5d025b7..1caefe1 100644 --- a/web/dashboard/src/components/settings/settings-types.ts +++ b/web/dashboard/src/components/settings/settings-types.ts @@ -1,11 +1,12 @@ -export type SettingsGroupId = "shop" | "printer"; +export type SettingsGroupId = "shop" | "printer" | "team"; export type SettingsLeafId = | "shop-general" | "shop-appearance" | "shop-discover" | "printer-config" - | "print-test"; + | "print-test" + | "team-custom-roles"; export type SettingsNavGroup = { id: SettingsGroupId; @@ -31,10 +32,19 @@ export const SETTINGS_NAV: SettingsNavGroup[] = [ { id: "print-test", labelKey: "nav.printTest" }, ], }, + { + id: "team", + labelKey: "nav.team", + children: [ + { id: "team-custom-roles", labelKey: "nav.customRoles" }, + ], + }, ]; export const DEFAULT_SETTINGS_LEAF: SettingsLeafId = "shop-general"; export function groupForLeaf(leaf: SettingsLeafId): SettingsGroupId { - return leaf === "printer-config" || leaf === "print-test" ? "printer" : "shop"; + if (leaf === "printer-config" || leaf === "print-test") return "printer"; + if (leaf === "team-custom-roles") return "team"; + return "shop"; }