Compare commits
3 Commits
main
...
Dynamic_Fi
| Author | SHA1 | Date | |
|---|---|---|---|
| 84fbf88347 | |||
| 4320f92964 | |||
| bd4bd90e7b |
File diff suppressed because one or more lines are too long
@ -1,106 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Marco.Pms.DataAccess.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Corrected_JobTicketId_Spelling_In_JobAttendance : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_JobAttendance_JobTickets_JobTcketId",
|
||||
table: "JobAttendance");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_JobAttendanceLogs_JobTickets_JobTcketId",
|
||||
table: "JobAttendanceLogs");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "JobTcketId",
|
||||
table: "JobAttendanceLogs",
|
||||
newName: "JobTicketId");
|
||||
|
||||
migrationBuilder.RenameIndex(
|
||||
name: "IX_JobAttendanceLogs_JobTcketId",
|
||||
table: "JobAttendanceLogs",
|
||||
newName: "IX_JobAttendanceLogs_JobTicketId");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "JobTcketId",
|
||||
table: "JobAttendance",
|
||||
newName: "JobTicketId");
|
||||
|
||||
migrationBuilder.RenameIndex(
|
||||
name: "IX_JobAttendance_JobTcketId",
|
||||
table: "JobAttendance",
|
||||
newName: "IX_JobAttendance_JobTicketId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_JobAttendance_JobTickets_JobTicketId",
|
||||
table: "JobAttendance",
|
||||
column: "JobTicketId",
|
||||
principalTable: "JobTickets",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_JobAttendanceLogs_JobTickets_JobTicketId",
|
||||
table: "JobAttendanceLogs",
|
||||
column: "JobTicketId",
|
||||
principalTable: "JobTickets",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_JobAttendance_JobTickets_JobTicketId",
|
||||
table: "JobAttendance");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_JobAttendanceLogs_JobTickets_JobTicketId",
|
||||
table: "JobAttendanceLogs");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "JobTicketId",
|
||||
table: "JobAttendanceLogs",
|
||||
newName: "JobTcketId");
|
||||
|
||||
migrationBuilder.RenameIndex(
|
||||
name: "IX_JobAttendanceLogs_JobTicketId",
|
||||
table: "JobAttendanceLogs",
|
||||
newName: "IX_JobAttendanceLogs_JobTcketId");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "JobTicketId",
|
||||
table: "JobAttendance",
|
||||
newName: "JobTcketId");
|
||||
|
||||
migrationBuilder.RenameIndex(
|
||||
name: "IX_JobAttendance_JobTicketId",
|
||||
table: "JobAttendance",
|
||||
newName: "IX_JobAttendance_JobTcketId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_JobAttendance_JobTickets_JobTcketId",
|
||||
table: "JobAttendance",
|
||||
column: "JobTcketId",
|
||||
principalTable: "JobTickets",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_JobAttendanceLogs_JobTickets_JobTcketId",
|
||||
table: "JobAttendanceLogs",
|
||||
column: "JobTcketId",
|
||||
principalTable: "JobTickets",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -5054,7 +5054,7 @@ namespace Marco.Pms.DataAccess.Migrations
|
||||
b.Property<Guid>("EmployeeId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<Guid>("JobTicketId")
|
||||
b.Property<Guid>("JobTcketId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime>("TaggedInAt")
|
||||
@ -5076,7 +5076,7 @@ namespace Marco.Pms.DataAccess.Migrations
|
||||
|
||||
b.HasIndex("EmployeeId");
|
||||
|
||||
b.HasIndex("JobTicketId");
|
||||
b.HasIndex("JobTcketId");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
@ -5104,7 +5104,7 @@ namespace Marco.Pms.DataAccess.Migrations
|
||||
b.Property<Guid>("JobAttendanceId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<Guid>("JobTicketId")
|
||||
b.Property<Guid>("JobTcketId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<string>("Latitude")
|
||||
@ -5130,7 +5130,7 @@ namespace Marco.Pms.DataAccess.Migrations
|
||||
|
||||
b.HasIndex("JobAttendanceId");
|
||||
|
||||
b.HasIndex("JobTicketId");
|
||||
b.HasIndex("JobTcketId");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
@ -8253,7 +8253,7 @@ namespace Marco.Pms.DataAccess.Migrations
|
||||
|
||||
b.HasOne("Marco.Pms.Model.ServiceProject.JobTicket", "JobTicket")
|
||||
.WithMany()
|
||||
.HasForeignKey("JobTicketId")
|
||||
.HasForeignKey("JobTcketId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
@ -8290,7 +8290,7 @@ namespace Marco.Pms.DataAccess.Migrations
|
||||
|
||||
b.HasOne("Marco.Pms.Model.ServiceProject.JobTicket", "JobTicket")
|
||||
.WithMany()
|
||||
.HasForeignKey("JobTicketId")
|
||||
.HasForeignKey("JobTcketId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ namespace Marco.Pms.Model.Dtos.ServiceProject
|
||||
{
|
||||
public class JobAttendanceDto
|
||||
{
|
||||
public required Guid JobTicketId { get; set; }
|
||||
public required Guid JobTcketId { get; set; }
|
||||
public required TAGGING_MARK_TYPE Action { get; set; }
|
||||
public string? Latitude { get; set; }
|
||||
public string? Longitude { get; set; }
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
public class AdvanceFilter
|
||||
{
|
||||
// The dynamic filters from your JSON
|
||||
public DateDynamicFilter? DateFilter { get; set; }
|
||||
public List<ListDynamicFilter>? Filters { get; set; }
|
||||
public List<SortItem>? SortFilters { get; set; }
|
||||
public List<SearchItem>? SearchFilters { get; set; }
|
||||
public List<AdvanceItem>? AdvanceFilters { get; set; }
|
||||
|
||||
9
Marco.Pms.Model/Filters/DateDynamicFilter.cs
Normal file
9
Marco.Pms.Model/Filters/DateDynamicFilter.cs
Normal file
@ -0,0 +1,9 @@
|
||||
namespace Marco.Pms.Model.Filters
|
||||
{
|
||||
public class DateDynamicFilter
|
||||
{
|
||||
public string Column { get; set; } = string.Empty;
|
||||
public DateTime StartValue { get; set; }
|
||||
public DateTime EndValue { get; set; }
|
||||
}
|
||||
}
|
||||
8
Marco.Pms.Model/Filters/ListDynamicFilter.cs
Normal file
8
Marco.Pms.Model/Filters/ListDynamicFilter.cs
Normal file
@ -0,0 +1,8 @@
|
||||
namespace Marco.Pms.Model.Filters
|
||||
{
|
||||
public class ListDynamicFilter
|
||||
{
|
||||
public string Column { get; set; } = string.Empty;
|
||||
public List<Guid> Values { get; set; } = new List<Guid>();
|
||||
}
|
||||
}
|
||||
@ -8,10 +8,10 @@ namespace Marco.Pms.Model.ServiceProject
|
||||
public class JobAttendance : TenantRelation
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid JobTicketId { get; set; }
|
||||
public Guid JobTcketId { get; set; }
|
||||
|
||||
[ValidateNever]
|
||||
[ForeignKey("JobTicketId")]
|
||||
[ForeignKey("JobTcketId")]
|
||||
public JobTicket? JobTicket { get; set; }
|
||||
public TAGGING_MARK_TYPE Action { get; set; }
|
||||
public Guid EmployeeId { get; set; }
|
||||
|
||||
@ -14,10 +14,10 @@ namespace Marco.Pms.Model.ServiceProject
|
||||
[ValidateNever]
|
||||
[ForeignKey("JobAttendanceId")]
|
||||
public JobAttendance? JobAttendance { get; set; }
|
||||
public Guid JobTicketId { get; set; }
|
||||
public Guid JobTcketId { get; set; }
|
||||
|
||||
[ValidateNever]
|
||||
[ForeignKey("JobTicketId")]
|
||||
[ForeignKey("JobTcketId")]
|
||||
public JobTicket? JobTicket { get; set; }
|
||||
public Guid? DocumentId { get; set; }
|
||||
|
||||
|
||||
@ -168,12 +168,23 @@ namespace Marco.Pms.Services.Controllers
|
||||
|
||||
query = query.ApplyCustomFilters(advanceFilter, "InvoiceDate");
|
||||
|
||||
if (advanceFilter?.SearchFilters != null)
|
||||
if (advanceFilter != null)
|
||||
{
|
||||
var invoiceSearchFilter = advanceFilter.SearchFilters.Where(f => f.Column != "ProjectName").ToList();
|
||||
if (invoiceSearchFilter.Any())
|
||||
if (advanceFilter.Filters != null)
|
||||
{
|
||||
query = query.ApplySearchFilters(invoiceSearchFilter);
|
||||
query = query.ApplyListFilters(advanceFilter.Filters);
|
||||
}
|
||||
if (advanceFilter.DateFilter != null)
|
||||
{
|
||||
query = query.ApplyDateFilter(advanceFilter.DateFilter);
|
||||
}
|
||||
if (advanceFilter.SearchFilters != null)
|
||||
{
|
||||
var invoiceSearchFilter = advanceFilter.SearchFilters.Where(f => f.Column != "ProjectName").ToList();
|
||||
if (invoiceSearchFilter.Any())
|
||||
{
|
||||
query = query.ApplySearchFilters(invoiceSearchFilter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
using Marco.Pms.Model.Filters;
|
||||
using System.Data;
|
||||
using System.Linq.Dynamic.Core;
|
||||
|
||||
namespace Marco.Pms.Services.Extensions
|
||||
@ -69,9 +70,16 @@ namespace Marco.Pms.Services.Extensions
|
||||
return query;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies search filters to the given IQueryable.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the elements in the IQueryable.</typeparam>
|
||||
/// <param name="query">The IQueryable to apply the filters to.</param>
|
||||
/// <param name="searchFilters">The list of search filters to apply.</param>
|
||||
/// <returns>The filtered IQueryable.</returns>
|
||||
public static IQueryable<T> ApplySearchFilters<T>(this IQueryable<T> query, List<SearchItem> searchFilters)
|
||||
{
|
||||
// 1. Apply Search Filters (Contains/Text search)
|
||||
// Apply search filters to the query
|
||||
if (searchFilters.Any())
|
||||
{
|
||||
foreach (var search in searchFilters)
|
||||
@ -86,10 +94,70 @@ namespace Marco.Pms.Services.Extensions
|
||||
return query;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies group by filters to the given IQueryable.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the elements in the IQueryable.</typeparam>
|
||||
/// <param name="query">The IQueryable to apply the filters to.</param>
|
||||
/// <param name="groupByColumn">The column to group by.</param>
|
||||
/// <returns>The grouped IQueryable.</returns>
|
||||
public static IQueryable<T> ApplyGroupByFilters<T>(this IQueryable<T> query, string groupByColumn)
|
||||
{
|
||||
// Group the query by the specified column and reshape the result to { Key: "Value", Items: [...] }
|
||||
query.GroupBy(groupByColumn, "it")
|
||||
.Select("new (Key, it as Items)"); // Reshape to { Key: "Value", Items: [...] }
|
||||
.Select("new (Key, it as Items)");
|
||||
return query;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies list filters to the given IQueryable.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the elements in the IQueryable.</typeparam>
|
||||
/// <param name="query">The IQueryable to apply the filters to.</param>
|
||||
/// <param name="filters">The list of filters to apply.</param>
|
||||
/// <returns>The filtered IQueryable.</returns>
|
||||
public static IQueryable<T> ApplyListFilters<T>(this IQueryable<T> query, List<ListDynamicFilter> filters)
|
||||
{
|
||||
// Check if there are any filters
|
||||
if (filters == null || !filters.Any()) return query;
|
||||
|
||||
// Apply filters to the query
|
||||
foreach (var filter in filters)
|
||||
{
|
||||
// Skip if column is empty or values are null or empty
|
||||
if (string.IsNullOrWhiteSpace(filter.Column) || filter.Values == null || !filter.Values.Any()) continue;
|
||||
|
||||
// Apply filter to the query
|
||||
query = query.Where($"@0.Contains({filter.Column})", filter.Values);
|
||||
}
|
||||
|
||||
// Return the filtered query
|
||||
return query;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies date filters to the given IQueryable.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the elements in the IQueryable.</typeparam>
|
||||
/// <param name="query">The IQueryable to apply the filters to.</param>
|
||||
/// <param name="dateFilter">The date filter to apply.</param>
|
||||
/// <returns>The filtered IQueryable.</returns>
|
||||
public static IQueryable<T> ApplyDateFilter<T>(this IQueryable<T> query, DateDynamicFilter dateFilter)
|
||||
{
|
||||
// Check if date filter is null or column is empty
|
||||
if (dateFilter == null || string.IsNullOrWhiteSpace(dateFilter.Column)) return query;
|
||||
|
||||
// Convert start and end values to date
|
||||
var startValue = dateFilter.StartValue.Date;
|
||||
var endValue = dateFilter.EndValue.Date.AddDays(1);
|
||||
|
||||
// Apply a filter to include items with a date greater than or equal to the start value
|
||||
query = query.Where($"{dateFilter.Column} >= @0", startValue);
|
||||
|
||||
// Apply a filter to include items with a date less than or equal to the end value
|
||||
query = query.Where($"{dateFilter.Column} < @0", endValue);
|
||||
|
||||
// Return the filtered IQueryable
|
||||
return query;
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,6 +21,7 @@ using Marco.Pms.Model.ViewModels.Expenses;
|
||||
using Marco.Pms.Model.ViewModels.Expenses.Masters;
|
||||
using Marco.Pms.Model.ViewModels.Master;
|
||||
using Marco.Pms.Model.ViewModels.Projects;
|
||||
using Marco.Pms.Services.Extensions;
|
||||
using Marco.Pms.Services.Helpers;
|
||||
using Marco.Pms.Services.Service.ServiceInterfaces;
|
||||
using MarcoBMS.Services.Service;
|
||||
@ -128,7 +129,7 @@ namespace Marco.Pms.Services.Service
|
||||
|
||||
|
||||
// 2. --- Deserialize Filter and Apply ---
|
||||
ExpensesFilter? expenseFilter = TryDeserializeFilter(filter);
|
||||
AdvanceFilter? advanceFilter = TryDeserializeAdvanceFilter(filter);
|
||||
|
||||
//var (totalPages, totalCount, cacheList) = await _cache.GetExpenseListAsync(tenantId, loggedInEmployeeId, hasViewAllPermissionTask.Result, hasViewSelfPermissionTask.Result,
|
||||
// pageNumber, pageSize, expenseFilter, searchString);
|
||||
@ -168,45 +169,28 @@ namespace Marco.Pms.Services.Service
|
||||
_logger.LogInfo("User {EmployeeId} has 'View Self' permission. Restricting query to their expenses.", loggedInEmployeeId);
|
||||
expensesQuery = expensesQuery.Where(e => e.CreatedById == loggedInEmployeeId);
|
||||
}
|
||||
|
||||
if (expenseFilter != null)
|
||||
expensesQuery = expensesQuery.ApplyCustomFilters(advanceFilter, "CreatedAt");
|
||||
if (advanceFilter != null)
|
||||
{
|
||||
if (expenseFilter.StartDate.HasValue && expenseFilter.EndDate.HasValue)
|
||||
if (advanceFilter.Filters != null)
|
||||
{
|
||||
if (expenseFilter.IsTransactionDate)
|
||||
expensesQuery = expensesQuery.ApplyListFilters(advanceFilter.Filters);
|
||||
}
|
||||
if (advanceFilter.DateFilter != null)
|
||||
{
|
||||
expensesQuery = expensesQuery.ApplyDateFilter(advanceFilter.DateFilter);
|
||||
}
|
||||
if (advanceFilter.SearchFilters != null)
|
||||
{
|
||||
var invoiceSearchFilter = advanceFilter.SearchFilters.Where(f => f.Column != "ProjectName").ToList();
|
||||
if (invoiceSearchFilter.Any())
|
||||
{
|
||||
expensesQuery = expensesQuery.Where(e => e.TransactionDate.Date >= expenseFilter.StartDate.Value.Date && e.TransactionDate.Date <= expenseFilter.EndDate.Value.Date);
|
||||
expensesQuery = expensesQuery.ApplySearchFilters(invoiceSearchFilter);
|
||||
}
|
||||
else
|
||||
{
|
||||
expensesQuery = expensesQuery.Where(e => e.CreatedAt.Date >= expenseFilter.StartDate.Value.Date && e.CreatedAt.Date <= expenseFilter.EndDate.Value.Date);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (expenseFilter.ProjectIds?.Any() == true)
|
||||
if (!string.IsNullOrWhiteSpace(advanceFilter.GroupByColumn))
|
||||
{
|
||||
expensesQuery = expensesQuery.Where(e => expenseFilter.ProjectIds.Contains(e.ProjectId));
|
||||
}
|
||||
|
||||
if (expenseFilter.StatusIds?.Any() == true)
|
||||
{
|
||||
expensesQuery = expensesQuery.Where(e => expenseFilter.StatusIds.Contains(e.StatusId));
|
||||
}
|
||||
|
||||
if (expenseFilter.PaidById?.Any() == true)
|
||||
{
|
||||
expensesQuery = expensesQuery.Where(e => expenseFilter.PaidById.Contains(e.PaidById));
|
||||
}
|
||||
if (expenseFilter.ExpenseCategoryIds?.Any() == true)
|
||||
{
|
||||
expensesQuery = expensesQuery.Where(e => expenseFilter.ExpenseCategoryIds.Contains(e.ExpenseCategoryId));
|
||||
}
|
||||
|
||||
// Only allow filtering by 'CreatedBy' if the user has 'View All' permission.
|
||||
if (expenseFilter.CreatedByIds?.Any() == true && hasViewAllPermissionTask.Result)
|
||||
{
|
||||
expensesQuery = expensesQuery.Where(e => expenseFilter.CreatedByIds.Contains(e.CreatedById));
|
||||
expensesQuery = expensesQuery.ApplyGroupByFilters(advanceFilter.GroupByColumn);
|
||||
}
|
||||
}
|
||||
|
||||
@ -228,7 +212,7 @@ namespace Marco.Pms.Services.Service
|
||||
|
||||
// 5. --- Execute Query and Map Results ---
|
||||
var expensesList = await expensesQuery
|
||||
.OrderByDescending(e => e.CreatedAt)
|
||||
//.OrderByDescending(e => e.CreatedAt)
|
||||
.Skip((pageNumber - 1) * pageSize)
|
||||
.Take(pageSize).ToListAsync();
|
||||
|
||||
@ -302,7 +286,6 @@ namespace Marco.Pms.Services.Service
|
||||
|
||||
var response = new
|
||||
{
|
||||
CurrentFilter = expenseFilter,
|
||||
CurrentPage = pageNumber,
|
||||
TotalPages = totalPages,
|
||||
TotalEntites = totalEntites,
|
||||
@ -3746,6 +3729,45 @@ namespace Marco.Pms.Services.Service
|
||||
#endregion
|
||||
|
||||
#region =================================================================== Helper Functions ===================================================================
|
||||
|
||||
private AdvanceFilter? TryDeserializeAdvanceFilter(string? filter)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filter))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
||||
AdvanceFilter? advanceFilter = null;
|
||||
|
||||
try
|
||||
{
|
||||
// First, try to deserialize directly. This is the expected case (e.g., from a web client).
|
||||
advanceFilter = JsonSerializer.Deserialize<AdvanceFilter>(filter, options);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogError(ex, "[{MethodName}] Failed to directly deserialize filter. Attempting to unescape and re-parse. Filter: {Filter}", nameof(TryDeserializeFilter), filter);
|
||||
|
||||
// If direct deserialization fails, it might be an escaped string (common with tools like Postman or some mobile clients).
|
||||
try
|
||||
{
|
||||
// Unescape the string first, then deserialize the result.
|
||||
string unescapedJsonString = JsonSerializer.Deserialize<string>(filter, options) ?? "";
|
||||
if (!string.IsNullOrWhiteSpace(unescapedJsonString))
|
||||
{
|
||||
advanceFilter = JsonSerializer.Deserialize<AdvanceFilter>(unescapedJsonString, options);
|
||||
}
|
||||
}
|
||||
catch (JsonException ex1)
|
||||
{
|
||||
// If both attempts fail, log the final error and return null.
|
||||
_logger.LogError(ex1, "[{MethodName}] All attempts to deserialize the filter failed. Filter will be ignored. Filter: {Filter}", nameof(TryDeserializeFilter), filter);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return advanceFilter;
|
||||
}
|
||||
private async Task<bool> HasPermissionAsync(Guid permission, Guid employeeId)
|
||||
{
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
|
||||
@ -3,7 +3,6 @@ using Marco.Pms.Model.Employees;
|
||||
using Marco.Pms.Model.Entitlements;
|
||||
using Marco.Pms.Services.Helpers;
|
||||
using MarcoBMS.Services.Helpers;
|
||||
using MarcoBMS.Services.Service;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Marco.Pms.Services.Service
|
||||
@ -13,18 +12,49 @@ namespace Marco.Pms.Services.Service
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly RolesHelper _rolesHelper;
|
||||
private readonly CacheUpdateHelper _cache;
|
||||
private readonly ILoggingService _logger;
|
||||
private readonly Guid tenantId;
|
||||
|
||||
public PermissionServices(ApplicationDbContext context, RolesHelper rolesHelper, CacheUpdateHelper cache, ILoggingService logger, UserHelper userHelper)
|
||||
public PermissionServices(ApplicationDbContext context, RolesHelper rolesHelper, CacheUpdateHelper cache, UserHelper userHelper)
|
||||
{
|
||||
_context = context;
|
||||
_rolesHelper = rolesHelper;
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
tenantId = userHelper.GetTenantId();
|
||||
}
|
||||
|
||||
//public async Task<bool> HasPermission(Guid featurePermissionId, Guid employeeId, Guid? projectId = null)
|
||||
//{
|
||||
// var featurePermissionIds = await _cache.GetPermissions(employeeId);
|
||||
// if (featurePermissionIds == null)
|
||||
// {
|
||||
// List<FeaturePermission> featurePermission = await _rolesHelper.GetFeaturePermissionByEmployeeId(employeeId);
|
||||
// featurePermissionIds = featurePermission.Select(fp => fp.Id).ToList();
|
||||
// }
|
||||
// if (projectId != null)
|
||||
// {
|
||||
// var projectLevelPerissionIds = await _context.ProjectLevelPermissionMappings
|
||||
// .Where(pl => pl.ProjectId == projectId.Value && pl.EmployeeId == employeeId).Select(pl => pl.PermissionId).ToListAsync();
|
||||
|
||||
// var projectLevelModuleIds = new HashSet<Guid>
|
||||
// {
|
||||
// Guid.Parse("53176ebf-c75d-42e5-839f-4508ffac3def"),
|
||||
// Guid.Parse("9d4b5489-2079-40b9-bd77-6e1bf90bc19f"),
|
||||
// Guid.Parse("81ab8a87-8ccd-4015-a917-0627cee6a100"),
|
||||
// Guid.Parse("52c9cf54-1eb2-44d2-81bb-524cf29c0a94"),
|
||||
// Guid.Parse("a8cf4331-8f04-4961-8360-a3f7c3cc7462")
|
||||
// };
|
||||
|
||||
// var allProjectLevelPermissionIds = await _context.FeaturePermissions
|
||||
// .Where(fp => projectLevelModuleIds.Contains(fp.FeatureId) && !projectLevelPerissionIds.Contains(fp.Id)).Select(fp => fp.Id).ToListAsync();
|
||||
// featurePermissionIds.RemoveRange(allProjectLevelPermissionIds);
|
||||
|
||||
// featurePermissionIds.AddRange(projectLevelPerissionIds);
|
||||
// featurePermissionIds = featurePermissionIds.Distinct().ToList();
|
||||
// }
|
||||
// var hasPermission = featurePermissionIds.Contains(featurePermissionId);
|
||||
// return hasPermission;
|
||||
//}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether an employee has a specific feature permission, optionally within a project context.
|
||||
/// </summary>
|
||||
@ -126,78 +156,5 @@ namespace Marco.Pms.Services.Service
|
||||
}
|
||||
return projectIds.Contains(projectId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines if an employee has permission to access a specific service project.
|
||||
/// Permission is granted if the user is directly allocated to the project OR
|
||||
/// assigned to any active job ticket within the project.
|
||||
/// </summary>
|
||||
/// <param name="loggedInEmployeeId">The ID of the user requesting access.</param>
|
||||
/// <param name="projectId">The ID of the project to access.</param>
|
||||
/// <returns>True if access is allowed, otherwise False.</returns>
|
||||
public async Task<bool> HasServiceProjectPermission(Guid loggedInEmployeeId, Guid projectId)
|
||||
{
|
||||
Guid ReviewDoneStatus = Guid.Parse("ed10ab57-dbaa-4ca5-8ecd-56745dcbdbd7");
|
||||
Guid ClosedStatus = Guid.Parse("3ddeefb5-ae3c-4e10-a922-35e0a452bb69");
|
||||
// 1. Input Validation
|
||||
if (loggedInEmployeeId == Guid.Empty || projectId == Guid.Empty)
|
||||
{
|
||||
_logger.LogWarning("Permission check failed: Invalid input parameters. EmployeeId: {EmployeeId}, ProjectId: {ProjectId}", loggedInEmployeeId, projectId);
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogInfo("Starting permission check for Employee: {EmployeeId} on Project: {ProjectId}", loggedInEmployeeId, projectId);
|
||||
|
||||
// 2. Check Level 1: Is the user a generic Team Member of the project?
|
||||
// This is usually the most common case, so checking this first saves complex query execution.
|
||||
bool isTeamMember = await _context.ServiceProjectAllocations
|
||||
.AsNoTracking() // Optimization: Read-only query does not need tracking
|
||||
.AnyAsync(spa => spa.ProjectId == projectId
|
||||
&& spa.EmployeeId == loggedInEmployeeId
|
||||
&& spa.IsActive
|
||||
&& spa.TenantId == tenantId);
|
||||
|
||||
if (isTeamMember)
|
||||
{
|
||||
_logger.LogInfo("Access Granted: User {EmployeeId} is a team member of Project {ProjectId}.", loggedInEmployeeId, projectId);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 3. Check Level 2: Is the user assigned to any ACTIVE specific Job Ticket?
|
||||
// Optimization: Combined the check for JobTicket and Mapping into a single Join query.
|
||||
// This prevents pulling a list of JobIds into memory (fixing memory bloat) and reduces DB roundtrips.
|
||||
bool hasActiveJobAssignment = await _context.JobTickets
|
||||
.AsNoTracking()
|
||||
.Where(jt => jt.ProjectId == projectId
|
||||
&& jt.StatusId != ReviewDoneStatus
|
||||
&& jt.StatusId != ClosedStatus
|
||||
&& jt.IsActive)
|
||||
.Join(_context.JobEmployeeMappings,
|
||||
ticket => ticket.Id,
|
||||
mapping => mapping.JobTicketId,
|
||||
(ticket, mapping) => mapping)
|
||||
.AnyAsync(mapping => mapping.AssigneeId == loggedInEmployeeId
|
||||
&& mapping.TenantId == tenantId);
|
||||
|
||||
if (hasActiveJobAssignment)
|
||||
{
|
||||
_logger.LogInfo("Access Granted: User {EmployeeId} is assigned active tickets in Project {ProjectId}.", loggedInEmployeeId, projectId);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 4. Default Deny
|
||||
_logger.LogWarning("Access Denied: User {EmployeeId} has no permissions for Project {ProjectId}.", loggedInEmployeeId, projectId);
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 5. Robust Error Handling
|
||||
// Log the full stack trace for debugging, but return false to maintain security (fail-closed).
|
||||
_logger.LogError(ex, "An error occurred while checking permissions for Employee: {EmployeeId} on Project: {ProjectId}", loggedInEmployeeId, projectId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -36,8 +36,8 @@ namespace Marco.Pms.Services.Service
|
||||
private readonly Guid NewStatus = Guid.Parse("32d76a02-8f44-4aa0-9b66-c3716c45a918");
|
||||
private readonly Guid AssignedStatus = Guid.Parse("cfa1886d-055f-4ded-84c6-42a2a8a14a66");
|
||||
private readonly Guid InProgressStatus = Guid.Parse("5a6873a5-fed7-4745-a52f-8f61bf3bd72d");
|
||||
private readonly Guid WorkDoneStatus = Guid.Parse("aab71020-2fb8-44d9-9430-c9a7e9bf33b0");
|
||||
private readonly Guid ReviewDoneStatus = Guid.Parse("ed10ab57-dbaa-4ca5-8ecd-56745dcbdbd7");
|
||||
private readonly Guid ReviewStatus = Guid.Parse("aab71020-2fb8-44d9-9430-c9a7e9bf33b0");
|
||||
private readonly Guid DoneStatus = Guid.Parse("ed10ab57-dbaa-4ca5-8ecd-56745dcbdbd7");
|
||||
private readonly Guid ClosedStatus = Guid.Parse("3ddeefb5-ae3c-4e10-a922-35e0a452bb69");
|
||||
private readonly Guid OnHoldStatus = Guid.Parse("75a0c8b8-9c6a-41af-80bf-b35bab722eb2");
|
||||
|
||||
@ -130,8 +130,8 @@ namespace Marco.Pms.Services.Service
|
||||
.Select(g => new
|
||||
{
|
||||
ProjectId = g.Key,
|
||||
JobsPassedDueDateCount = g.Count(jt => jt.StatusId != ReviewDoneStatus && jt.StatusId != ClosedStatus && jt.DueDate.Date < DateTime.UtcNow.Date),
|
||||
ActiveJobsCount = g.Count(jt => jt.StatusId != ReviewDoneStatus && jt.StatusId != ClosedStatus && jt.StatusId != OnHoldStatus),
|
||||
JobsPassedDueDateCount = g.Count(jt => jt.StatusId != DoneStatus && jt.StatusId != ClosedStatus && jt.DueDate.Date < DateTime.UtcNow.Date),
|
||||
ActiveJobsCount = g.Count(jt => jt.StatusId != DoneStatus && jt.StatusId != ClosedStatus && jt.StatusId != OnHoldStatus),
|
||||
AssignedJobsCount = g.Count(jt => jt.StatusId == AssignedStatus),
|
||||
OnHoldJobsCount = g.Count(jt => jt.StatusId == OnHoldStatus)
|
||||
})
|
||||
@ -1700,7 +1700,7 @@ namespace Marco.Pms.Services.Service
|
||||
// Fetch the most recent attendance record for the logged-in employee for the specified job
|
||||
var jobAttendance = await _context.JobAttendance
|
||||
.AsNoTracking()
|
||||
.Where(ja => ja.JobTicketId == jobTicket.Id && ja.EmployeeId == loggedInEmployee.Id && ja.TenantId == tenantId)
|
||||
.Where(ja => ja.JobTcketId == jobTicket.Id && ja.EmployeeId == loggedInEmployee.Id && ja.TenantId == tenantId)
|
||||
.OrderByDescending(ja => ja.TaggedInTime)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
@ -2164,7 +2164,7 @@ namespace Marco.Pms.Services.Service
|
||||
if (jobTicket.IsArchive != model.IsArchive)
|
||||
{
|
||||
// Validate if job ticket status permits archiving
|
||||
if (model.IsArchive && jobTicket.StatusId != ReviewDoneStatus && jobTicket.StatusId != ClosedStatus)
|
||||
if (model.IsArchive && jobTicket.StatusId != DoneStatus && jobTicket.StatusId != ClosedStatus)
|
||||
{
|
||||
_logger.LogWarning("Archiving failed: Job status not eligible. JobTicketId: {JobTicketId}, StatusId: {StatusId}", jobTicket.Id, jobTicket.StatusId);
|
||||
return ApiResponse<object>.ErrorResponse(
|
||||
@ -2866,7 +2866,7 @@ namespace Marco.Pms.Services.Service
|
||||
.AsNoTracking()
|
||||
.Include(ja => ja.JobTicket).ThenInclude(jt => jt!.Status)
|
||||
.Include(ja => ja.Employee).ThenInclude(e => e!.JobRole)
|
||||
.Where(ja => ja.JobTicketId == jobTicketId && ja.EmployeeId == loggedInEmployee.Id && ja.TenantId == tenantId)
|
||||
.Where(ja => ja.JobTcketId == jobTicketId && ja.EmployeeId == loggedInEmployee.Id && ja.TenantId == tenantId)
|
||||
.OrderByDescending(ja => ja.TaggedInTime)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
@ -3016,7 +3016,7 @@ namespace Marco.Pms.Services.Service
|
||||
.AsNoTracking()
|
||||
.Include(ja => ja.JobTicket).ThenInclude(jt => jt!.Status)
|
||||
.Include(ja => ja.Employee).ThenInclude(e => e!.JobRole)
|
||||
.Where(ja => ja.JobTicketId == jobTicketId
|
||||
.Where(ja => ja.JobTcketId == jobTicketId
|
||||
&& ja.TaggedInTime.Date >= fromDate
|
||||
&& ja.TaggedInTime.Date <= toDate
|
||||
&& ja.TenantId == tenantId)
|
||||
@ -3056,7 +3056,7 @@ namespace Marco.Pms.Services.Service
|
||||
return ApiResponse<object>.ErrorResponse("Access Denied", "Invalid tenant context.", 403);
|
||||
}
|
||||
|
||||
_logger.LogInfo("ManageJobTaggingAsync called for EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}", loggedInEmployee.Id, model.JobTicketId);
|
||||
_logger.LogInfo("ManageJobTaggingAsync called for EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}", loggedInEmployee.Id, model.JobTcketId);
|
||||
|
||||
try
|
||||
{
|
||||
@ -3064,27 +3064,27 @@ namespace Marco.Pms.Services.Service
|
||||
var jobTicket = await _context.JobTickets
|
||||
.AsNoTracking()
|
||||
.Include(jt => jt.Status)
|
||||
.FirstOrDefaultAsync(jt => jt.Id == model.JobTicketId && jt.TenantId == tenantId && !jt.IsArchive);
|
||||
.FirstOrDefaultAsync(jt => jt.Id == model.JobTcketId && jt.TenantId == tenantId && !jt.IsArchive);
|
||||
if (jobTicket == null)
|
||||
{
|
||||
_logger.LogWarning("JobTicket not found. JobTicketId: {JobTicketId}, TenantId: {TenantId}", model.JobTicketId, tenantId);
|
||||
_logger.LogWarning("JobTicket not found. JobTicketId: {JobTicketId}, TenantId: {TenantId}", model.JobTcketId, tenantId);
|
||||
return ApiResponse<object>.ErrorResponse("The job could not be found. Please check the job details and try again.", "The job could not be found. Please check the job details and try again.", 404);
|
||||
}
|
||||
|
||||
// Check if the current user is part of the job team
|
||||
var jobEmployeeMapping = await _context.JobEmployeeMappings
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(jem => jem.JobTicketId == model.JobTicketId && jem.AssigneeId == loggedInEmployee.Id && jem.TenantId == tenantId);
|
||||
.FirstOrDefaultAsync(jem => jem.JobTicketId == model.JobTcketId && jem.AssigneeId == loggedInEmployee.Id && jem.TenantId == tenantId);
|
||||
if (jobEmployeeMapping == null)
|
||||
{
|
||||
_logger.LogWarning("User is not part of job team. EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}", loggedInEmployee.Id, model.JobTicketId);
|
||||
_logger.LogWarning("User is not part of job team. EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}", loggedInEmployee.Id, model.JobTcketId);
|
||||
return ApiResponse<object>.ErrorResponse("User is not part of job team", "User is not part of job team", 400);
|
||||
}
|
||||
|
||||
// Get the last attendance record for the user and job
|
||||
var jobAttendance = await _context.JobAttendance
|
||||
.AsNoTracking()
|
||||
.Where(ja => ja.EmployeeId == loggedInEmployee.Id && ja.JobTicketId == model.JobTicketId)
|
||||
.Where(ja => ja.EmployeeId == loggedInEmployee.Id && ja.JobTcketId == model.JobTcketId)
|
||||
.OrderByDescending(ja => ja.TaggedInAt)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
@ -3103,7 +3103,7 @@ namespace Marco.Pms.Services.Service
|
||||
var newJobAttendance = new JobAttendance
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
JobTicketId = model.JobTicketId,
|
||||
JobTcketId = model.JobTcketId,
|
||||
EmployeeId = loggedInEmployee.Id,
|
||||
Action = TAGGING_MARK_TYPE.TAG_IN,
|
||||
TaggedInTime = markedAt,
|
||||
@ -3112,7 +3112,7 @@ namespace Marco.Pms.Services.Service
|
||||
};
|
||||
_context.JobAttendance.Add(newJobAttendance);
|
||||
updateJobAttendance = newJobAttendance;
|
||||
_logger.LogInfo("New Tag In created for EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}", loggedInEmployee.Id, model.JobTicketId);
|
||||
_logger.LogInfo("New Tag In created for EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}", loggedInEmployee.Id, model.JobTcketId);
|
||||
}
|
||||
else if (isLastTaggedOut && jobAttendance.TaggedInTime.Date == currentTime.Date)
|
||||
{
|
||||
@ -3125,11 +3125,11 @@ namespace Marco.Pms.Services.Service
|
||||
|
||||
_context.JobAttendance.Update(jobAttendance);
|
||||
updateJobAttendance = jobAttendance;
|
||||
_logger.LogInfo("Existing JobAttendance updated to Tag In for EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}", loggedInEmployee.Id, model.JobTicketId);
|
||||
_logger.LogInfo("Existing JobAttendance updated to Tag In for EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}", loggedInEmployee.Id, model.JobTcketId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Attempted to Tag In without tagging out last session. EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}", loggedInEmployee.Id, model.JobTicketId);
|
||||
_logger.LogWarning("Attempted to Tag In without tagging out last session. EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}", loggedInEmployee.Id, model.JobTcketId);
|
||||
return ApiResponse<object>.ErrorResponse("First, mark the last tag as out before tagging in.", "First, mark the last tag as out before tagging in.", 400);
|
||||
}
|
||||
}
|
||||
@ -3144,17 +3144,17 @@ namespace Marco.Pms.Services.Service
|
||||
|
||||
_context.JobAttendance.Update(jobAttendance);
|
||||
updateJobAttendance = jobAttendance;
|
||||
_logger.LogInfo("JobAttendance updated to Tag Out for EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}", loggedInEmployee.Id, model.JobTicketId);
|
||||
_logger.LogInfo("JobAttendance updated to Tag Out for EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}", loggedInEmployee.Id, model.JobTcketId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Attempted to Tag Out without previous Tag In. EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}", loggedInEmployee.Id, model.JobTicketId);
|
||||
_logger.LogWarning("Attempted to Tag Out without previous Tag In. EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}", loggedInEmployee.Id, model.JobTcketId);
|
||||
return ApiResponse<object>.ErrorResponse("First, mark the last tag as in before tagging out.", "First, mark the last tag as in before tagging out.", 400);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Invalid action provided: {Action}. EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}", model.Action, loggedInEmployee.Id, model.JobTicketId);
|
||||
_logger.LogWarning("Invalid action provided: {Action}. EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}", model.Action, loggedInEmployee.Id, model.JobTcketId);
|
||||
return ApiResponse<object>.ErrorResponse("Provided invalid action", "Provided invalid action", 400);
|
||||
}
|
||||
|
||||
@ -3166,7 +3166,7 @@ namespace Marco.Pms.Services.Service
|
||||
string base64 = model.Attachment.Base64Data?.Split(',').LastOrDefault() ?? "";
|
||||
if (string.IsNullOrWhiteSpace(base64))
|
||||
{
|
||||
_logger.LogWarning("Base64 data missing in attachment. EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}", loggedInEmployee.Id, model.JobTicketId);
|
||||
_logger.LogWarning("Base64 data missing in attachment. EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}", loggedInEmployee.Id, model.JobTcketId);
|
||||
return ApiResponse<object>.ErrorResponse("Base64 data is missing", "Attachment data missing", 400);
|
||||
}
|
||||
|
||||
@ -3189,7 +3189,7 @@ namespace Marco.Pms.Services.Service
|
||||
};
|
||||
|
||||
_context.Documents.Add(document);
|
||||
_logger.LogInfo("Attachment uploaded and document record created. EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}, DocumentId: {DocumentId}", loggedInEmployee.Id, model.JobTicketId, document.Id);
|
||||
_logger.LogInfo("Attachment uploaded and document record created. EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}, DocumentId: {DocumentId}", loggedInEmployee.Id, model.JobTcketId, document.Id);
|
||||
}
|
||||
|
||||
// Create attendance log entry for audit trail
|
||||
@ -3204,7 +3204,7 @@ namespace Marco.Pms.Services.Service
|
||||
Longitude = model.Longitude,
|
||||
Comment = model.Comment,
|
||||
JobAttendanceId = updateJobAttendance.Id,
|
||||
JobTicketId = model.JobTicketId,
|
||||
JobTcketId = model.JobTcketId,
|
||||
DocumentId = document?.Id,
|
||||
TenantId = tenantId
|
||||
};
|
||||
@ -3218,13 +3218,13 @@ namespace Marco.Pms.Services.Service
|
||||
response.JobTicket = _mapper.Map<BasicJobTicketVM>(jobTicket);
|
||||
response.Employee = _mapper.Map<BasicEmployeeVM>(loggedInEmployee);
|
||||
|
||||
_logger.LogInfo("Tagging managed successfully for EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}", loggedInEmployee.Id, model.JobTicketId);
|
||||
_logger.LogInfo("Tagging managed successfully for EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}", loggedInEmployee.Id, model.JobTcketId);
|
||||
|
||||
return ApiResponse<object>.SuccessResponse(response, "Tagging managed successfully", 200);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error occurred in ManageJobTaggingAsync for EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}", loggedInEmployee.Id, model.JobTicketId);
|
||||
_logger.LogError(ex, "Error occurred in ManageJobTaggingAsync for EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}", loggedInEmployee.Id, model.JobTcketId);
|
||||
return ApiResponse<object>.ErrorResponse("An unexpected error occurred.", ex.Message, 500);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user