using FluentValidation; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Meezi.API.Models.Hr; using Meezi.API.Services; using Meezi.Core.Authorization; using Meezi.Core.Entities; using Meezi.Core.Enums; using Meezi.Core.Interfaces; using Meezi.Core.Utilities; using Meezi.Infrastructure.Data; using Meezi.Shared; namespace Meezi.API.Controllers; [Route("api/cafes/{cafeId}")] public class HrController : CafeApiControllerBase { private readonly IHrService _hr; private readonly IValidator _leaveValidator; private readonly IValidator _reviewValidator; private readonly IValidator _salaryValidator; private readonly AppDbContext _db; public HrController( IHrService hr, IValidator leaveValidator, IValidator reviewValidator, IValidator salaryValidator, AppDbContext db) { _hr = hr; _leaveValidator = leaveValidator; _reviewValidator = reviewValidator; _salaryValidator = salaryValidator; _db = db; } [HttpGet("employees")] public async Task GetEmployees( string cafeId, ITenantContext tenant, [FromQuery] string? branchId = null, CancellationToken ct = default) { if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsurePermission(tenant, Permission.ViewStaff) is { } forbidden) return forbidden; var data = await _hr.GetEmployeesAsync(cafeId, branchId, ct); return Ok(new ApiResponse>(true, data)); } /// Create a new employee (waiter, cashier, chef, …). Owner/Manager only; /// creating a Manager requires Owner. Optionally sets login credentials in one step. [HttpPost("employees")] public async Task CreateEmployee( string cafeId, [FromBody] CreateEmployeeRequest request, ITenantContext tenant, CancellationToken ct) { if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsurePermission(tenant, Permission.CreateStaff) is { } forbidden) return forbidden; IActionResult Invalid(string message, string field) => BadRequest(new ApiResponse(false, null, new ApiError("VALIDATION_ERROR", message, field))); var name = request.Name?.Trim() ?? string.Empty; if (string.IsNullOrWhiteSpace(name)) return Invalid("Name is required.", "Name"); var phone = request.Phone?.Trim() ?? string.Empty; if (string.IsNullOrWhiteSpace(phone)) return Invalid("Phone is required.", "Phone"); if (!Enum.IsDefined(typeof(EmployeeRole), request.Role)) return Invalid("Invalid role.", "Role"); // An Owner is created only at café registration, never via this endpoint. if (request.Role == EmployeeRole.Owner) return Invalid("Cannot create an owner here.", "Role"); // Only an Owner may add a Manager. if (request.Role == EmployeeRole.Manager && EnsureOwner(tenant) is { } ownerOnly) return ownerOnly; // One employee per phone within a café. var phoneTaken = await _db.Employees .AnyAsync(e => e.CafeId == cafeId && e.DeletedAt == null && e.Phone == phone, ct); if (phoneTaken) return Conflict(new ApiResponse(false, null, new ApiError("PHONE_TAKEN", "An employee with this phone already exists.", "Phone"))); string? branchId = string.IsNullOrWhiteSpace(request.BranchId) ? null : request.BranchId.Trim(); if (branchId is not null) { var branchOk = await _db.Branches.AnyAsync(b => b.Id == branchId && b.CafeId == cafeId, ct); if (!branchOk) return Invalid("Invalid branch.", "BranchId"); } var employee = new Employee { Id = $"emp_{Guid.NewGuid():N}"[..24], CafeId = cafeId, BranchId = branchId, Name = name, Phone = phone, Role = request.Role, BaseSalary = request.BaseSalary ?? 0m, NationalId = string.IsNullOrWhiteSpace(request.NationalId) ? null : request.NationalId.Trim(), CreatedAt = DateTime.UtcNow, }; // Optional: enable password login in the same step. var wantsCreds = !string.IsNullOrWhiteSpace(request.Username) || !string.IsNullOrWhiteSpace(request.Password); if (wantsCreds) { var username = (request.Username ?? string.Empty).Trim().ToLowerInvariant(); if (string.IsNullOrWhiteSpace(username)) return Invalid("Username is required when setting a password.", "Username"); if ((request.Password ?? string.Empty).Length < 8) return Invalid("Password must be at least 8 characters.", "Password"); var usernameTaken = await _db.Employees .AnyAsync(e => e.CafeId == cafeId && e.DeletedAt == null && e.Username != null && e.Username.ToLower() == username, ct); if (usernameTaken) return Conflict(new ApiResponse(false, null, new ApiError("USERNAME_TAKEN", "This username is already in use by another employee.", "Username"))); employee.Username = username; employee.PasswordHash = PasswordHasher.Hash(request.Password!); } _db.Employees.Add(employee); await _db.SaveChangesAsync(ct); var dto = new EmployeeSummaryDto(employee.Id, employee.Name, employee.Phone, employee.Role, employee.BaseSalary); return Ok(new ApiResponse(true, dto)); } [HttpGet("employees/{employeeId}")] public async Task GetEmployee(string cafeId, string employeeId, ITenantContext tenant, CancellationToken ct) { if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; var data = await _hr.GetEmployeeAsync(cafeId, employeeId, ct); if (data is null) return NotFoundError(); return Ok(new ApiResponse(true, data)); } [HttpGet("employees/{employeeId}/shift/today")] public async Task GetTodayShift(string cafeId, string employeeId, ITenantContext tenant, CancellationToken ct) { if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsureSelfOrManager(employeeId, tenant) is { } forbidden) return forbidden; var data = await _hr.GetTodayShiftAsync(cafeId, employeeId, ct); if (data is null) return NotFoundError(); return Ok(new ApiResponse(true, data)); } [HttpPost("employees/{employeeId}/attendance/clock-in")] public async Task ClockIn(string cafeId, string employeeId, ITenantContext tenant, CancellationToken ct) { if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsureSelfOrManager(employeeId, tenant) is { } forbidden) return forbidden; var data = await _hr.ClockInAsync(cafeId, employeeId, ct); if (data is null) return NotFoundError(); return Ok(new ApiResponse(true, data)); } [HttpPost("employees/{employeeId}/attendance/clock-out")] public async Task ClockOut(string cafeId, string employeeId, ITenantContext tenant, CancellationToken ct) { if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsureSelfOrManager(employeeId, tenant) is { } forbidden) return forbidden; var data = await _hr.ClockOutAsync(cafeId, employeeId, ct); if (data is null) return BadRequest(new ApiResponse(false, null, new ApiError("INVALID", "Clock-in required before clock-out."))); return Ok(new ApiResponse(true, data)); } [HttpGet("attendance")] public async Task GetAttendance( string cafeId, [FromQuery] string? employeeId, [FromQuery] DateOnly? from, [FromQuery] DateOnly? to, ITenantContext tenant, CancellationToken ct) { if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsurePermission(tenant, Permission.ViewAttendance) is { } forbidden) return forbidden; var data = await _hr.GetAttendanceAsync(cafeId, employeeId, from, to, ct); return Ok(new ApiResponse>(true, data)); } [HttpGet("employees/{employeeId}/shifts")] public async Task GetShifts(string cafeId, string employeeId, ITenantContext tenant, CancellationToken ct) { if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsurePermission(tenant, Permission.ViewSchedules) is { } forbidden) return forbidden; var data = await _hr.GetShiftsAsync(cafeId, employeeId, ct); return Ok(new ApiResponse>(true, data)); } [HttpPut("employees/{employeeId}/shifts")] public async Task UpsertShifts( string cafeId, string employeeId, [FromBody] UpsertShiftsRequest request, ITenantContext tenant, CancellationToken ct) { if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsurePermission(tenant, Permission.ManageSchedules) is { } forbidden) return forbidden; var data = await _hr.UpsertShiftsAsync(cafeId, employeeId, request, ct); return Ok(new ApiResponse>(true, data)); } [HttpGet("leave-requests")] public async Task GetLeaveRequests( string cafeId, [FromQuery] LeaveStatus? status, ITenantContext tenant, CancellationToken ct) { if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsurePermission(tenant, Permission.ReviewLeave) is { } forbidden) return forbidden; var data = await _hr.GetLeaveRequestsAsync(cafeId, status, ct); return Ok(new ApiResponse>(true, data)); } [HttpPost("employees/{employeeId}/leave-requests")] public async Task CreateLeave( string cafeId, string employeeId, [FromBody] CreateLeaveRequest request, ITenantContext tenant, CancellationToken ct) { if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsureSelfOrManager(employeeId, tenant) is { } forbidden) return forbidden; var validation = await _leaveValidator.ValidateAsync(request, ct); if (!validation.IsValid) return BadRequest(ValidationError(validation)); var data = await _hr.CreateLeaveRequestAsync(cafeId, employeeId, request, ct); if (data is null) return NotFoundError(); return Ok(new ApiResponse(true, data)); } [HttpPatch("leave-requests/{leaveId}/status")] public async Task ReviewLeave( string cafeId, string leaveId, [FromBody] ReviewLeaveRequest request, ITenantContext tenant, CancellationToken ct) { if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsurePermission(tenant, Permission.ReviewLeave) is { } forbidden) return forbidden; var validation = await _reviewValidator.ValidateAsync(request, ct); if (!validation.IsValid) return BadRequest(ValidationError(validation)); var data = await _hr.ReviewLeaveRequestAsync(cafeId, leaveId, tenant.UserId!, request, ct); if (data is null) return NotFoundError(); return Ok(new ApiResponse(true, data)); } [HttpGet("salaries")] public async Task GetSalaries( string cafeId, [FromQuery] string? monthYear, ITenantContext tenant, CancellationToken ct) { if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsurePermission(tenant, Permission.ViewSalaries) is { } forbidden) return forbidden; var data = await _hr.GetSalariesAsync(cafeId, monthYear, ct); return Ok(new ApiResponse>(true, data)); } [HttpPost("salaries")] public async Task CreateSalary( string cafeId, [FromBody] CreateSalaryRequest request, ITenantContext tenant, CancellationToken ct) { if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsurePermission(tenant, Permission.ManageSalaries) is { } forbidden) return forbidden; var validation = await _salaryValidator.ValidateAsync(request, ct); if (!validation.IsValid) return BadRequest(ValidationError(validation)); var data = await _hr.CreateSalaryAsync(cafeId, request, ct); if (data is null) return NotFoundError(); return Ok(new ApiResponse(true, data)); } [HttpPatch("salaries/{salaryId}/paid")] public async Task MarkPaid(string cafeId, string salaryId, ITenantContext tenant, CancellationToken ct) { if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsurePermission(tenant, Permission.ManageSalaries) is { } forbidden) return forbidden; var data = await _hr.MarkSalaryPaidAsync(cafeId, salaryId, ct); if (data is null) return NotFoundError(); return Ok(new ApiResponse(true, data)); } /// Set or update username/password credentials for an employee. Owner/Manager only. [HttpPut("employees/{employeeId}/credentials")] public async Task SetCredentials( string cafeId, string employeeId, [FromBody] SetEmployeeCredentialsRequest request, ITenantContext tenant, CancellationToken ct) { if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsurePermission(tenant, Permission.ManageStaffCredentials) is { } forbidden) return forbidden; var username = request.Username.Trim().ToLowerInvariant(); if (string.IsNullOrWhiteSpace(username)) return BadRequest(new ApiResponse(false, null, new ApiError("VALIDATION_ERROR", "Username is required.", "Username"))); if (request.Password.Length < 8) return BadRequest(new ApiResponse(false, null, new ApiError("VALIDATION_ERROR", "Password must be at least 8 characters.", "Password"))); var employee = await _db.Employees .FirstOrDefaultAsync(e => e.Id == employeeId && e.CafeId == cafeId && e.DeletedAt == null, ct); if (employee is null) return NotFoundError(); // Check username uniqueness within the cafe (excluding the employee itself) var conflict = await _db.Employees .AnyAsync(e => e.CafeId == cafeId && e.Id != employeeId && e.DeletedAt == null && e.Username != null && e.Username.ToLower() == username, ct); if (conflict) return Conflict(new ApiResponse(false, null, new ApiError("USERNAME_TAKEN", "This username is already in use by another employee."))); employee.Username = username; employee.PasswordHash = PasswordHasher.Hash(request.Password); await _db.SaveChangesAsync(ct); return Ok(new ApiResponse(true, null)); } /// Remove username/password credentials from an employee. Owner/Manager only. [HttpDelete("employees/{employeeId}/credentials")] public async Task RemoveCredentials( string cafeId, string employeeId, ITenantContext tenant, CancellationToken ct) { if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsurePermission(tenant, Permission.ManageStaffCredentials) 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.Username = null; employee.PasswordHash = null; await _db.SaveChangesAsync(ct); return Ok(new ApiResponse(true, null)); } }