Compare commits

..

39 Commits

Author SHA1 Message Date
fa4c39904a chnage loan list to new type 2026-02-03 13:44:47 +03:30
0e7787dd56 add page size for search model 2026-02-01 18:14:28 +03:30
87c3cebb60 set workshopId to readonly 2026-01-13 09:38:44 +03:30
607c0780b6 Merge branch 'master' into Feature/loan/client-api 2026-01-10 11:50:52 +03:30
587fa40d81 fix percnetage condition 2026-01-10 10:45:38 +03:30
b741ab9ed2 fix contains no element error for empty skills 2026-01-10 10:34:20 +03:30
b6fde4903a Merge branch 'Feature/institution-contract/sent-to-customer-flag' 2026-01-08 15:03:07 +03:30
0772604432 feat: enhance GetMessagesQuery to include additional time notes in message retrieval 2026-01-08 15:02:43 +03:30
59891d1199 Merge remote-tracking branch 'origin/master' 2026-01-08 14:16:22 +03:30
7cb39b1b92 feat: add UserId filter to ProjectBoardListQuery for enhanced task assignment tracking 2026-01-08 14:16:08 +03:30
SamSys
5580d56874 change logger on program.cs 2026-01-08 14:14:27 +03:30
SamSys
423b49e6e7 Merge branch 'master' of https://github.com/samsyntax24/OriginalGozareshgir 2026-01-08 14:05:30 +03:30
38027352d6 Merge branch 'Feature/program-manager/priority'
# Conflicts:
#	ProgramManager/src/Application/GozareshgirProgramManager.Application/Modules/Projects/Queries/ProjectBoardList/ProjectBoardListResponse.cs
2026-01-08 14:00:49 +03:30
43562fb49c Merge branch 'Feature/program-manager/chat'
# Conflicts:
#	.gitignore
#	ServiceHost/appsettings.Development.json
#	ServiceHost/appsettings.json
2026-01-08 13:51:06 +03:30
80a58f8cdc feat: add TaskId to ProjectBoardListQueryHandler and ProjectBoardListResponse for enhanced task tracking 2026-01-08 12:14:26 +03:30
7c611825a4 feat: refactor task priority handling to use ProjectTaskPriority across commands, DTOs, and repositories 2026-01-08 12:09:18 +03:30
ef49302f8a feat: add workshop ID handling in LoanController for loan search filtering 2026-01-08 12:03:54 +03:30
SamSys
bf46dfd1dc Merge branch 'master' of https://github.com/samsyntax24/OriginalGozareshgir 2026-01-08 11:35:10 +03:30
SamSys
a1ed3ad648 logeer change 2026-01-08 11:35:03 +03:30
8679abb1e7 feat: enhance ChangeTaskPriorityCommand to support multi-level priority updates for tasks, phases, and projects 2026-01-08 11:18:15 +03:30
c8dddabdff fix: correct logic for editing text messages in TaskChatMessage.cs 2026-01-08 11:05:36 +03:30
6f076bdc77 feat: update application URL in launchSettings and enhance task sorting by priority in ProjectBoardListQueryHandler 2026-01-08 10:46:04 +03:30
380ed8f6b1 feat: update SetTimeProjectCommandHandler to set status to Incomplete when additional time is added 2026-01-07 18:34:37 +03:30
7423391003 Merge branch 'Feature/program-manager/priority'
# Conflicts:
#	ServiceHost/Areas/Admin/Controllers/ProgramManager/ProjectController.cs
2026-01-07 18:05:46 +03:30
572f66f905 feat: implement auto-pending for task sections reaching estimated time 2026-01-07 16:52:50 +03:30
2bea265989 feat: implement task priority change command and update project/task DTOs 2026-01-07 12:11:24 +03:30
ef9b78b924 Merge branch 'refs/heads/master' into Feature/program-manager/priority 2026-01-07 12:01:38 +03:30
95d66c2d89 feat: enhance message queries to display real sender names and add system notes for additional times 2026-01-07 11:17:16 +03:30
609daf4353 feat: update file storage paths and enhance thumbnail generation with category support 2026-01-07 10:50:23 +03:30
a81e01ce2b Remove Storage folder from git tracking 2026-01-07 10:44:09 +03:30
2cd838a5e3 feat: enhance thumbnail generation with category support and update storage paths 2026-01-07 10:25:33 +03:30
a9789023ac feat: add HTTP POST endpoint for ChangePriority method in ProjectController 2026-01-06 13:46:45 +03:30
34bd7ba444 add set file message to TaskChatMessage.cs 2026-01-06 12:21:12 +03:30
16b11a8bb8 feat: add ChangePriority method to ProjectController for task priority updates 2026-01-06 10:51:56 +03:30
43b124664e feat: integrate authentication checks in message command handlers 2026-01-05 16:06:35 +03:30
d2dd67343b feat: add file management entities and services for chat message handling 2026-01-05 15:40:06 +03:30
3d2b5ff6bd feat: implement TaskChatMessage entity and repository for chat message management 2026-01-05 11:45:37 +03:30
4ab9f60932 feat: add methods for creating, calculating installments, and removing loans in LoanController 2026-01-01 14:41:13 +03:30
9cfae54db3 feat: add LoanController for managing loan applications and details 2026-01-01 13:13:04 +03:30
118 changed files with 4365 additions and 51 deletions

4
.gitignore vendored
View File

@@ -364,3 +364,7 @@ MigrationBackup/
.idea
/ServiceHost/appsettings.Development.json
/ServiceHost/appsettings.json
# Storage folder - ignore all uploaded files, thumbnails, and temporary files
ServiceHost/Storage

View File

@@ -21,6 +21,6 @@ public class LoanGroupedViewModel
{
public List<LoanGroupedByDateViewModel> GroupedByDate { get; set; }
public List<LoanGroupedByEmployeeViewModel>GroupedByEmployee { get; set; }
public List<LoanViewModel> LoanListViewModel { get; set; }
public PagedResult<LoanViewModel> LoanListViewModel { get; set; }
}

View File

@@ -16,5 +16,6 @@ public class LoanSearchViewModel
public string EndDate { get; set; }
public int PageIndex { get; set; }
public int PageSize { get; set; } = 30;
public bool ShowAsGrouped { get; set; }
}

View File

@@ -171,22 +171,29 @@ public class LoanRepository : RepositoryBase<long, Loan>, ILoanRepository
query = query.Where(x => x.StartInstallmentPayment >= startDate && x.StartInstallmentPayment <= endDate);
}
result.LoanListViewModel = query.OrderByDescending(x => x.StartInstallmentPayment).Skip(searchModel.PageIndex)
.Take(30).ToList()
.Select(x => new LoanViewModel()
{
EmployeeFullName = employees.FirstOrDefault(e => e.id == x.EmployeeId).FullName,
PersonnelCode = personnelCodes.FirstOrDefault(p => p.EmployeeId == x.EmployeeId).PersonnelCode.ToString(),
Amount = x.Amount.ToMoney(),
AmountPerMonth = x.AmountPerMonth.ToMoney(),
StartDateTime = x.StartInstallmentPayment.ToFarsi(),
Count = x.Count,
Id = x.id,
WorkshopId = x.WorkshopId,
EmployeeId = x.EmployeeId,
YearFa = pc.GetYear(x.StartInstallmentPayment).ToString(),
result.LoanListViewModel = new PagedResult<LoanViewModel>()
{
TotalCount = query.Count(),
List = query.OrderByDescending(x => x.StartInstallmentPayment)
.ApplyPagination(searchModel.PageIndex, searchModel.PageSize)
.Take(30).ToList()
.Select(x => new LoanViewModel()
{
EmployeeFullName = employees.FirstOrDefault(e => e.id == x.EmployeeId).FullName,
PersonnelCode = personnelCodes.FirstOrDefault(p => p.EmployeeId == x.EmployeeId).PersonnelCode
.ToString(),
Amount = x.Amount.ToMoney(),
AmountPerMonth = x.AmountPerMonth.ToMoney(),
StartDateTime = x.StartInstallmentPayment.ToFarsi(),
Count = x.Count,
Id = x.id,
WorkshopId = x.WorkshopId,
EmployeeId = x.EmployeeId,
YearFa = pc.GetYear(x.StartInstallmentPayment).ToString(),
}).ToList()
};
}).ToList();
return result;
}

View File

@@ -89,6 +89,9 @@ EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BackgroundInstitutionContract.Task", "BackgroundInstitutionContract\BackgroundInstitutionContract.Task\BackgroundInstitutionContract.Task.csproj", "{F78FBB92-294B-88BA-168D-F0C578B0D7D6}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ProgramManager", "ProgramManager", "{67AFF7B6-4C4F-464C-A90D-9BDB644D83A9}"
ProjectSection(SolutionItems) = preProject
ProgramManager\appsettings.FileStorage.json = ProgramManager\appsettings.FileStorage.json
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{48F6F6A5-7340-42F8-9216-BEB7A4B7D5A1}"
EndProject

View File

@@ -9,6 +9,7 @@
<ItemGroup>
<PackageReference Include="FluentValidation" Version="12.1.1" />
<PackageReference Include="MediatR" Version="14.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
</ItemGroup>
@@ -18,4 +19,10 @@
<ProjectReference Include="..\..\Domain\GozareshgirProgramManager.Domain\GozareshgirProgramManager.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<Reference Include="Microsoft.AspNetCore.Http.Features">
<HintPath>C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App\10.0.1\Microsoft.AspNetCore.Http.Features.dll</HintPath>
</Reference>
</ItemGroup>
</Project>

View File

@@ -10,7 +10,7 @@ public record AddTaskToPhaseCommand(
Guid PhaseId,
string Name,
string? Description = null,
TaskPriority Priority = TaskPriority.Medium,
ProjectTaskPriority Priority = ProjectTaskPriority.Medium,
int OrderIndex = 0,
DateTime? DueDate = null
) : IBaseCommand;

View File

@@ -0,0 +1,62 @@
using GozareshgirProgramManager.Application._Common.Interfaces;
using GozareshgirProgramManager.Application._Common.Models;
using GozareshgirProgramManager.Domain._Common;
using GozareshgirProgramManager.Domain.ProjectAgg.Repositories;
using GozareshgirProgramManager.Domain.ProjectAgg.Enums;
namespace GozareshgirProgramManager.Application.Modules.Projects.Commands.AutoPendingFullTimeTaskSections;
public record AutoPendingFullTimeTaskSectionsCommand : IBaseCommand;
public class AutoPendingFullTimeTaskSectionsCommandHandler : IBaseCommandHandler<AutoPendingFullTimeTaskSectionsCommand>
{
private readonly ITaskSectionRepository _taskSectionRepository;
private readonly IUnitOfWork _unitOfWork;
public AutoPendingFullTimeTaskSectionsCommandHandler(
ITaskSectionRepository taskSectionRepository,
IUnitOfWork unitOfWork)
{
_taskSectionRepository = taskSectionRepository;
_unitOfWork = unitOfWork;
}
public async Task<OperationResult> Handle(AutoPendingFullTimeTaskSectionsCommand request, CancellationToken cancellationToken)
{
try
{
// تمام سکشن‌هایی که هنوز Pending یا Completed نشده‌اند را دریافت کن
var taskSections = await _taskSectionRepository.GetAllNotCompletedOrPendingIncludeAllAsync(cancellationToken);
foreach (var section in taskSections)
{
var totalSpent = section.GetTotalTimeSpent();
var estimate = section.FinalEstimatedHours;
if (estimate.TotalMinutes <= 0)
continue; // تسک بدون تخمین را نادیده بگیر
if (totalSpent >= estimate)
{
// مهم: وضعیت را مستقل از فعال/غیرفعال بودن فعالیت‌ها PendingForCompletion کنیم
if (section.IsInProgress())
{
// اگر فعالیت فعال دارد، با وضعیت جدید متوقف شود
section.StopWork(TaskSectionStatus.PendingForCompletion, "اتمام خودکار - رسیدن به ۱۰۰٪ زمان تخمینی");
}
else
{
section.UpdateStatus(TaskSectionStatus.PendingForCompletion);
}
}
}
await _unitOfWork.SaveChangesAsync(cancellationToken);
return OperationResult.Success();
}
catch (Exception ex)
{
return OperationResult.Failure($"خطا در در انتظار تکمیل قرار دادن خودکار تسک‌ها: {ex.Message}");
}
}
}

View File

@@ -0,0 +1,109 @@
using GozareshgirProgramManager.Application._Common.Interfaces;
using GozareshgirProgramManager.Application._Common.Models;
using GozareshgirProgramManager.Domain._Common;
using GozareshgirProgramManager.Domain.ProjectAgg.Enums;
using GozareshgirProgramManager.Domain.ProjectAgg.Repositories;
namespace GozareshgirProgramManager.Application.Modules.Projects.Commands.ChangeTaskPriority;
public record ChangeTaskPriorityCommand(
Guid Id,
ProjectHierarchyLevel Level,
ProjectTaskPriority Priority
) : IBaseCommand;
public class ChangeTaskPriorityCommandHandler : IBaseCommandHandler<ChangeTaskPriorityCommand>
{
private readonly IProjectTaskRepository _taskRepository;
private readonly IProjectPhaseRepository _phaseRepository;
private readonly IProjectRepository _projectRepository;
private readonly IUnitOfWork _unitOfWork;
public ChangeTaskPriorityCommandHandler(
IProjectTaskRepository taskRepository,
IProjectPhaseRepository phaseRepository,
IProjectRepository projectRepository,
IUnitOfWork unitOfWork)
{
_taskRepository = taskRepository;
_phaseRepository = phaseRepository;
_projectRepository = projectRepository;
_unitOfWork = unitOfWork;
}
public async Task<OperationResult> Handle(ChangeTaskPriorityCommand request, CancellationToken cancellationToken)
{
switch (request.Level)
{
case ProjectHierarchyLevel.Task:
return await HandleTaskLevelAsync(request.Id, request.Priority, cancellationToken);
case ProjectHierarchyLevel.Phase:
return await HandlePhaseLevelAsync(request.Id, request.Priority, cancellationToken);
case ProjectHierarchyLevel.Project:
return await HandleProjectLevelAsync(request.Id, request.Priority, cancellationToken);
default:
return OperationResult.Failure("سطح نامعتبر است");
}
}
// Task-level priority update
private async Task<OperationResult> HandleTaskLevelAsync(Guid taskId, ProjectTaskPriority priority, CancellationToken ct)
{
var task = await _taskRepository.GetByIdAsync(taskId, ct);
if (task is null)
return OperationResult.NotFound("تسک یافت نشد");
if (task.Priority != priority)
{
task.SetPriority(priority);
}
await _unitOfWork.SaveChangesAsync(ct);
return OperationResult.Success();
}
// Phase-level bulk priority update
private async Task<OperationResult> HandlePhaseLevelAsync(Guid phaseId, ProjectTaskPriority priority, CancellationToken ct)
{
var phase = await _phaseRepository.GetWithTasksAsync(phaseId);
if (phase is null)
return OperationResult.NotFound("فاز یافت نشد");
var tasks = phase.Tasks?.ToList() ?? new List<Domain.ProjectAgg.Entities.ProjectTask>();
foreach (var t in tasks)
{
if (t.Priority != priority)
{
t.SetPriority(priority);
}
}
await _unitOfWork.SaveChangesAsync(ct);
return OperationResult.Success();
}
// Project-level bulk priority update across all phases
private async Task<OperationResult> HandleProjectLevelAsync(Guid projectId, ProjectTaskPriority priority, CancellationToken ct)
{
var project = await _projectRepository.GetWithFullHierarchyAsync(projectId);
if (project is null)
return OperationResult.NotFound("پروژه یافت نشد");
var phases = project.Phases?.ToList() ?? new List<Domain.ProjectAgg.Entities.ProjectPhase>();
foreach (var phase in phases)
{
var tasks = phase.Tasks?.ToList() ?? new List<Domain.ProjectAgg.Entities.ProjectTask>();
foreach (var t in tasks)
{
if (t.Priority != priority)
{
t.SetPriority(priority);
}
}
}
await _unitOfWork.SaveChangesAsync(ct);
return OperationResult.Success();
}
}

View File

@@ -365,10 +365,26 @@ public class SetTimeProjectCommandHandler : IBaseCommandHandler<SetTimeProjectCo
section.ClearAdditionalTimes();
// افزودن زمان‌های اضافی
bool hasAdditionalTime = false;
foreach (var additionalTime in sectionItem.AdditionalTime)
{
var additionalTimeSpan = TimeSpan.FromHours(additionalTime.Hours).Add(TimeSpan.FromMinutes(additionalTime.Minutes));
section.AddAdditionalTime(additionalTimeSpan, additionalTime.Description, addedByUserId);
hasAdditionalTime = true;
}
// تغییر status به Incomplete فقط اگر زمان اضافی اضافه شده باشد و در وضعیتی غیر از ReadyToStart باشد
if (hasAdditionalTime && section.Status != TaskSectionStatus.ReadyToStart)
{
// اگر سکشن درحال انجام است، باید متوقف شود قبل از تغییر status
if (section.Status == TaskSectionStatus.InProgress)
{
section.StopWork(TaskSectionStatus.Incomplete);
}
else
{
section.UpdateStatus(TaskSectionStatus.Incomplete);
}
}
}

View File

@@ -189,6 +189,7 @@ public class GetProjectsListQueryHandler : IBaseQueryHandler<GetProjectsListQuer
SpentTime = spentTime,
RemainingTime = remainingTime,
Sections = sectionDtos,
Priority = task.Priority
});
}
return result;

View File

@@ -15,6 +15,7 @@ public class GetTaskDto
// Task-specific fields
public TimeSpan SpentTime { get; init; }
public TimeSpan RemainingTime { get; init; }
public ProjectTaskPriority Priority { get; set; }
public List<GetTaskSectionDto> Sections { get; init; }
}

View File

@@ -6,5 +6,6 @@ namespace GozareshgirProgramManager.Application.Modules.Projects.Queries.Project
public record ProjectBoardListQuery: IBaseQuery<List<ProjectBoardListResponse>>
{
public long? UserId { get; set; }
public TaskSectionStatus? Status { get; set; }
}

View File

@@ -40,6 +40,11 @@ public class ProjectBoardListQueryHandler : IBaseQueryHandler<ProjectBoardListQu
{
queryable = queryable.Where(x => x.Status == request.Status);
}
if (request.UserId is > 0)
{
queryable = queryable.Where(x => x.CurrentAssignedUserId == request.UserId);
}
var data = await queryable.ToListAsync(cancellationToken);
@@ -53,7 +58,9 @@ public class ProjectBoardListQueryHandler : IBaseQueryHandler<ProjectBoardListQu
.ToDictionaryAsync(x => x.Id, x => x.FullName, cancellationToken);
var result = data .OrderByDescending(x => x.CurrentAssignedUserId == currentUserId)
var result = data
.OrderByDescending(x => x.CurrentAssignedUserId == currentUserId)
.ThenByDescending(x=>x.Task.Priority)
.ThenBy(x => GetStatusOrder(x.Status))
.Select(x =>
{
@@ -103,6 +110,7 @@ public class ProjectBoardListQueryHandler : IBaseQueryHandler<ProjectBoardListQu
ProjectName = x.Task.Phase.Project.Name,
TaskName = x.Task.Name,
SectionStatus = x.Status,
TaskPriority = x.Task.Priority,
Progress = new ProjectProgressDto()
{
CompleteSecond = x.FinalEstimatedHours.TotalSeconds,
@@ -114,6 +122,7 @@ public class ProjectBoardListQueryHandler : IBaseQueryHandler<ProjectBoardListQu
AssignedUser = x.CurrentAssignedUserId == x.OriginalAssignedUserId ? null
: users.GetValueOrDefault(x.CurrentAssignedUserId, "ناشناس"),
SkillName = x.Skill?.Name??"-",
TaskId = x.TaskId
};
}).ToList();

View File

@@ -13,6 +13,8 @@ public class ProjectBoardListResponse
public string? AssignedUser { get; set; }
public string OriginalUser { get; set; }
public string SkillName { get; set; }
public ProjectTaskPriority TaskPriority { get; set; }
public Guid TaskId { get; set; }
}
public class ProjectProgressDto

View File

@@ -21,7 +21,7 @@ public record ProjectDeployBoardDetailTaskItem(
TimeSpan DoneTimeSpan,
int Percentage,
List<ProjectDeployBoardDetailItemSkill> Skills)
: ProjectDeployBoardDetailPhaseItem(Name, TotalTimeSpan, DoneTimeSpan,Percentage);
: ProjectDeployBoardDetailPhaseItem(Name, TotalTimeSpan, DoneTimeSpan, Percentage);
public record ProjectDeployBoardDetailItemSkill(string OriginalUserFullName, string SkillName, int TimePercentage);
@@ -73,6 +73,7 @@ public class
var doneTime = t.Sections.Aggregate(TimeSpan.Zero,
(sum, next) => sum.Add(next.GetTotalTimeSpent()));
var skills = t.Sections
.Select(s =>
{
@@ -82,13 +83,23 @@ public class
var skillName = s.Skill?.Name ?? "بدون مهارت";
var timePercentage = (int)s.GetProgressPercentage();
return new ProjectDeployBoardDetailItemSkill(
originalUserFullName,
skillName,
timePercentage);
}).ToList();
var taskPercentage = (int)Math.Round(skills.Average(x => x.TimePercentage));
int taskPercentage;
if (skills.Count == 0)
{
taskPercentage = 0;
}
else
{
taskPercentage = (int)Math.Round(skills.Average(x => x.TimePercentage));
}
return new ProjectDeployBoardDetailTaskItem(
t.Name,
@@ -105,7 +116,7 @@ public class
(sum, next) => sum.Add(next.DoneTimeSpan));
var phasePercentage = tasksRes.Average(x => x.Percentage);
var phaseRes = new ProjectDeployBoardDetailPhaseItem(phase.Name, totalTimeSpan, doneTimeSpan,
(int)phasePercentage);

View File

@@ -0,0 +1,47 @@
using GozareshgirProgramManager.Application._Common.Models;
using GozareshgirProgramManager.Application._Common.Interfaces;
using GozareshgirProgramManager.Domain._Common.Exceptions;
using GozareshgirProgramManager.Domain.TaskChatAgg.Repositories;
namespace GozareshgirProgramManager.Application.Modules.TaskChat.Commands.DeleteMessage;
public record DeleteMessageCommand(Guid MessageId) : IBaseCommand;
public class DeleteMessageCommandHandler : IBaseCommandHandler<DeleteMessageCommand>
{
private readonly ITaskChatMessageRepository _repository;
private readonly IAuthHelper _authHelper;
public DeleteMessageCommandHandler(ITaskChatMessageRepository repository, IAuthHelper authHelper)
{
_repository = repository;
_authHelper = authHelper;
}
public async Task<OperationResult> Handle(DeleteMessageCommand request, CancellationToken cancellationToken)
{
var currentUserId = _authHelper.GetCurrentUserId()??
throw new UnAuthorizedException("کاربر احراز هویت نشده است");
var message = await _repository.GetByIdAsync(request.MessageId);
if (message == null)
{
return OperationResult.NotFound("پیام یافت نشد");
}
try
{
message.DeleteMessage(currentUserId);
await _repository.UpdateAsync(message);
await _repository.SaveChangesAsync();
// TODO: SignalR notification
return OperationResult.Success();
}
catch (Exception ex)
{
return OperationResult.ValidationError(ex.Message);
}
}
}

View File

@@ -0,0 +1,51 @@
using GozareshgirProgramManager.Application._Common.Interfaces;
using GozareshgirProgramManager.Application._Common.Models;
using GozareshgirProgramManager.Domain._Common.Exceptions;
using GozareshgirProgramManager.Domain.TaskChatAgg.Repositories;
using MediatR;
namespace GozareshgirProgramManager.Application.Modules.TaskChat.Commands.EditMessage;
public record EditMessageCommand(
Guid MessageId,
string NewTextContent
) : IBaseCommand;
public class EditMessageCommandHandler : IBaseCommandHandler<EditMessageCommand>
{
private readonly ITaskChatMessageRepository _repository;
private readonly IAuthHelper _authHelper;
public EditMessageCommandHandler(ITaskChatMessageRepository repository, IAuthHelper authHelper)
{
_repository = repository;
_authHelper = authHelper;
}
public async Task<OperationResult> Handle(EditMessageCommand request, CancellationToken cancellationToken)
{
var currentUserId = _authHelper.GetCurrentUserId()??
throw new UnAuthorizedException("کاربر احراز هویت نشده است");
var message = await _repository.GetByIdAsync(request.MessageId);
if (message == null)
{
return OperationResult.NotFound("پیام یافت نشد");
}
try
{
message.EditMessage(request.NewTextContent, currentUserId);
await _repository.UpdateAsync(message);
await _repository.SaveChangesAsync();
// TODO: SignalR notification
return OperationResult.Success();
}
catch (Exception ex)
{
return OperationResult.ValidationError(ex.Message);
}
}
}

View File

@@ -0,0 +1,46 @@
using GozareshgirProgramManager.Application._Common.Interfaces;
using GozareshgirProgramManager.Application._Common.Models;
using GozareshgirProgramManager.Domain._Common.Exceptions;
using GozareshgirProgramManager.Domain.TaskChatAgg.Repositories;
using MediatR;
namespace GozareshgirProgramManager.Application.Modules.TaskChat.Commands.PinMessage;
public record PinMessageCommand(Guid MessageId) : IBaseCommand;
public class PinMessageCommandHandler : IBaseCommandHandler<PinMessageCommand>
{
private readonly ITaskChatMessageRepository _repository;
private readonly IAuthHelper _authHelper;
public PinMessageCommandHandler(ITaskChatMessageRepository repository, IAuthHelper authHelper)
{
_repository = repository;
_authHelper = authHelper;
}
public async Task<OperationResult> Handle(PinMessageCommand request, CancellationToken cancellationToken)
{
var currentUserId = _authHelper.GetCurrentUserId()??
throw new UnAuthorizedException("کاربر احراز هویت نشده است");
var message = await _repository.GetByIdAsync(request.MessageId);
if (message == null)
{
return OperationResult.NotFound("پیام یافت نشد");
}
try
{
message.PinMessage(currentUserId);
await _repository.UpdateAsync(message);
await _repository.SaveChangesAsync();
return OperationResult.Success();
}
catch (Exception ex)
{
return OperationResult.ValidationError(ex.Message);
}
}
}

View File

@@ -0,0 +1,212 @@
using GozareshgirProgramManager.Application._Common.Interfaces;
using GozareshgirProgramManager.Application._Common.Models;
using GozareshgirProgramManager.Application.Modules.TaskChat.DTOs;
using GozareshgirProgramManager.Application.Services.FileManagement;
using GozareshgirProgramManager.Domain._Common.Exceptions;
using GozareshgirProgramManager.Domain.TaskChatAgg.Entities;
using GozareshgirProgramManager.Domain.TaskChatAgg.Repositories;
using GozareshgirProgramManager.Domain.TaskChatAgg.Enums;
using GozareshgirProgramManager.Domain.FileManagementAgg.Entities;
using GozareshgirProgramManager.Domain.FileManagementAgg.Repositories;
using GozareshgirProgramManager.Domain.FileManagementAgg.Enums;
using GozareshgirProgramManager.Domain.ProjectAgg.Repositories;
using MediatR;
using Microsoft.AspNetCore.Http;
namespace GozareshgirProgramManager.Application.Modules.TaskChat.Commands.SendMessage;
public record SendMessageCommand(
Guid TaskId,
MessageType MessageType,
string? TextContent,
IFormFile? File,
Guid? ReplyToMessageId
) : IBaseCommand<MessageDto>;
public class SendMessageCommandHandler : IBaseCommandHandler<SendMessageCommand, MessageDto>
{
private readonly ITaskChatMessageRepository _messageRepository;
private readonly IUploadedFileRepository _fileRepository;
private readonly IProjectTaskRepository _taskRepository;
private readonly IFileStorageService _fileStorageService;
private readonly IThumbnailGeneratorService _thumbnailService;
private readonly IAuthHelper _authHelper;
public SendMessageCommandHandler(
ITaskChatMessageRepository messageRepository,
IUploadedFileRepository fileRepository,
IProjectTaskRepository taskRepository,
IFileStorageService fileStorageService,
IThumbnailGeneratorService thumbnailService, IAuthHelper authHelper)
{
_messageRepository = messageRepository;
_fileRepository = fileRepository;
_taskRepository = taskRepository;
_fileStorageService = fileStorageService;
_thumbnailService = thumbnailService;
_authHelper = authHelper;
}
public async Task<OperationResult<MessageDto>> Handle(SendMessageCommand request, CancellationToken cancellationToken)
{
var currentUserId = _authHelper.GetCurrentUserId()
?? throw new UnAuthorizedException("کاربر احراز هویت نشده است");
var task = await _taskRepository.GetByIdAsync(request.TaskId, cancellationToken);
if (task == null)
{
return OperationResult<MessageDto>.NotFound("تسک یافت نشد");
}
Guid? uploadedFileId = null;
if (request.File != null)
{
if (request.File.Length == 0)
{
return OperationResult<MessageDto>.ValidationError("فایل خالی است");
}
const long maxFileSize = 100 * 1024 * 1024;
if (request.File.Length > maxFileSize)
{
return OperationResult<MessageDto>.ValidationError("حجم فایل بیش از حد مجاز است (حداکثر 100MB)");
}
var fileType = DetectFileType(request.File.ContentType, Path.GetExtension(request.File.FileName));
var uploadedFile = new UploadedFile(
originalFileName: request.File.FileName,
fileSizeBytes: request.File.Length,
mimeType: request.File.ContentType,
fileType: fileType,
category: FileCategory.TaskChatMessage,
uploadedByUserId: currentUserId,
storageProvider: StorageProvider.LocalFileSystem
);
await _fileRepository.AddAsync(uploadedFile);
await _fileRepository.SaveChangesAsync();
try
{
using var stream = request.File.OpenReadStream();
var uploadResult = await _fileStorageService.UploadAsync(
stream,
uploadedFile.UniqueFileName,
"TaskChatMessage"
);
uploadedFile.CompleteUpload(uploadResult.StoragePath, uploadResult.StorageUrl);
if (fileType == FileType.Image)
{
var dimensions = await _thumbnailService.GetImageDimensionsAsync(uploadResult.StoragePath);
if (dimensions.HasValue)
{
uploadedFile.SetImageDimensions(dimensions.Value.Width, dimensions.Value.Height);
}
var thumbnail = await _thumbnailService
.GenerateImageThumbnailAsync(uploadResult.StoragePath, category: "TaskChatMessage");
if (thumbnail.HasValue)
{
uploadedFile.SetThumbnail(thumbnail.Value.ThumbnailUrl);
}
}
await _fileRepository.UpdateAsync(uploadedFile);
await _fileRepository.SaveChangesAsync();
uploadedFileId = uploadedFile.Id;
}
catch (Exception ex)
{
await _fileRepository.DeleteAsync(uploadedFile);
await _fileRepository.SaveChangesAsync();
return OperationResult<MessageDto>.ValidationError($"خطا در آپلود فایل: {ex.Message}");
}
}
var message = new TaskChatMessage(
taskId: request.TaskId,
senderUserId: currentUserId,
messageType: request.MessageType,
textContent: request.TextContent,
uploadedFileId
);
if (request.ReplyToMessageId.HasValue)
{
message.SetReplyTo(request.ReplyToMessageId.Value);
}
await _messageRepository.AddAsync(message);
await _messageRepository.SaveChangesAsync();
if (uploadedFileId.HasValue)
{
var file = await _fileRepository.GetByIdAsync(uploadedFileId.Value);
if (file != null)
{
file.SetReference("TaskChatMessage", message.Id.ToString());
await _fileRepository.UpdateAsync(file);
await _fileRepository.SaveChangesAsync();
}
}
var dto = new MessageDto
{
Id = message.Id,
TaskId = message.TaskId,
SenderUserId = message.SenderUserId,
SenderName = "کاربر",
MessageType = message.MessageType.ToString(),
TextContent = message.TextContent,
ReplyToMessageId = message.ReplyToMessageId,
IsEdited = message.IsEdited,
IsPinned = message.IsPinned,
CreationDate = message.CreationDate,
IsMine = true
};
if (uploadedFileId.HasValue)
{
var file = await _fileRepository.GetByIdAsync(uploadedFileId.Value);
if (file != null)
{
dto.File = new MessageFileDto
{
Id = file.Id,
FileName = file.OriginalFileName,
FileUrl = file.StorageUrl ?? "",
FileSizeBytes = file.FileSizeBytes,
FileType = file.FileType.ToString(),
ThumbnailUrl = file.ThumbnailUrl,
ImageWidth = file.ImageWidth,
ImageHeight = file.ImageHeight,
DurationSeconds = file.DurationSeconds
};
}
}
return OperationResult<MessageDto>.Success(dto);
}
private FileType DetectFileType(string mimeType, string extension)
{
if (mimeType.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
return FileType.Image;
if (mimeType.StartsWith("video/", StringComparison.OrdinalIgnoreCase))
return FileType.Video;
if (mimeType.StartsWith("audio/", StringComparison.OrdinalIgnoreCase))
return FileType.Audio;
if (new[] { ".zip", ".rar", ".7z", ".tar", ".gz" }.Contains(extension.ToLower()))
return FileType.Archive;
return FileType.Document;
}
}

View File

@@ -0,0 +1,46 @@
using GozareshgirProgramManager.Application._Common.Interfaces;
using GozareshgirProgramManager.Application._Common.Models;
using GozareshgirProgramManager.Domain._Common.Exceptions;
using GozareshgirProgramManager.Domain.TaskChatAgg.Repositories;
using MediatR;
namespace GozareshgirProgramManager.Application.Modules.TaskChat.Commands.UnpinMessage;
public record UnpinMessageCommand(Guid MessageId) : IBaseCommand;
public class UnpinMessageCommandHandler : IBaseCommandHandler<UnpinMessageCommand>
{
private readonly ITaskChatMessageRepository _repository;
private readonly IAuthHelper _authHelper;
public UnpinMessageCommandHandler(ITaskChatMessageRepository repository, IAuthHelper authHelper)
{
_repository = repository;
_authHelper = authHelper;
}
public async Task<OperationResult> Handle(UnpinMessageCommand request, CancellationToken cancellationToken)
{
var currentUserId = _authHelper.GetCurrentUserId()??
throw new UnAuthorizedException("کاربر احراز هویت نشده است");
var message = await _repository.GetByIdAsync(request.MessageId);
if (message == null)
{
return OperationResult.NotFound("پیام یافت نشد");
}
try
{
message.UnpinMessage(currentUserId);
await _repository.UpdateAsync(message);
await _repository.SaveChangesAsync();
return OperationResult.Success();
}
catch (Exception ex)
{
return OperationResult.ValidationError(ex.Message);
}
}
}

View File

@@ -0,0 +1,63 @@
namespace GozareshgirProgramManager.Application.Modules.TaskChat.DTOs;
public class SendMessageDto
{
public Guid TaskId { get; set; }
public string MessageType { get; set; } = string.Empty; // "Text", "File", "Image", "Voice", "Video"
public string? TextContent { get; set; }
public Guid? FileId { get; set; }
public Guid? ReplyToMessageId { get; set; }
}
public class MessageDto
{
public Guid Id { get; set; }
public Guid TaskId { get; set; }
public long SenderUserId { get; set; }
public string SenderName { get; set; } = string.Empty;
public string MessageType { get; set; } = string.Empty;
public string? TextContent { get; set; }
public MessageFileDto? File { get; set; }
public Guid? ReplyToMessageId { get; set; }
public MessageDto? ReplyToMessage { get; set; }
public bool IsEdited { get; set; }
public DateTime? EditedDate { get; set; }
public bool IsPinned { get; set; }
public DateTime? PinnedDate { get; set; }
public long? PinnedByUserId { get; set; }
public DateTime CreationDate { get; set; }
public bool IsMine { get; set; }
}
public class MessageFileDto
{
public Guid Id { get; set; }
public string FileName { get; set; } = string.Empty;
public string FileUrl { get; set; } = string.Empty;
public long FileSizeBytes { get; set; }
public string FileType { get; set; } = string.Empty;
public string? ThumbnailUrl { get; set; }
public int? ImageWidth { get; set; }
public int? ImageHeight { get; set; }
public int? DurationSeconds { get; set; }
public string FileSizeFormatted
{
get
{
const long kb = 1024;
const long mb = kb * 1024;
const long gb = mb * 1024;
if (FileSizeBytes >= gb)
return $"{FileSizeBytes / (double)gb:F2} GB";
if (FileSizeBytes >= mb)
return $"{FileSizeBytes / (double)mb:F2} MB";
if (FileSizeBytes >= kb)
return $"{FileSizeBytes / (double)kb:F2} KB";
return $"{FileSizeBytes} Bytes";
}
}
}

View File

@@ -0,0 +1,208 @@
using GozareshgirProgramManager.Application._Common.Interfaces;
using GozareshgirProgramManager.Application._Common.Models;
using GozareshgirProgramManager.Application.Modules.TaskChat.DTOs;
using Microsoft.EntityFrameworkCore;
namespace GozareshgirProgramManager.Application.Modules.TaskChat.Queries.GetMessages;
public record GetMessagesQuery(
Guid TaskId,
int Page = 1,
int PageSize = 50
) : IBaseQuery<PaginationResult<MessageDto>>;
public class GetMessagesQueryHandler : IBaseQueryHandler<GetMessagesQuery, PaginationResult<MessageDto>>
{
private readonly IProgramManagerDbContext _context;
private readonly IAuthHelper _authHelper;
public GetMessagesQueryHandler(IProgramManagerDbContext context, IAuthHelper authHelper)
{
_context = context;
_authHelper = authHelper;
}
private List<MessageDto> CreateAdditionalTimeNotes(
IEnumerable<Domain.ProjectAgg.Entities.TaskSectionAdditionalTime> additionalTimes,
Dictionary<long, string> users,
Guid taskId)
{
var notes = new List<MessageDto>();
foreach (var additionalTime in additionalTimes)
{
var addedByUserName = additionalTime.AddedByUserId.HasValue && users.TryGetValue(additionalTime.AddedByUserId.Value, out var user)
? user
: "سیستم";
var noteContent = $"⏱️ زمان اضافی: {additionalTime.Hours.TotalHours.ToString("F2")} ساعت - {(string.IsNullOrWhiteSpace(additionalTime.Reason) ? "بدون علت" : additionalTime.Reason)} - توسط {addedByUserName}";
var noteDto = new MessageDto
{
Id = Guid.NewGuid(),
TaskId = taskId,
SenderUserId = 0,
SenderName = "سیستم",
MessageType = "Note",
TextContent = noteContent,
CreationDate = additionalTime.CreationDate,
IsMine = false
};
notes.Add(noteDto);
}
return notes;
}
public async Task<OperationResult<PaginationResult<MessageDto>>> Handle(GetMessagesQuery request, CancellationToken cancellationToken)
{
var currentUserId = _authHelper.GetCurrentUserId();
var skip = (request.Page - 1) * request.PageSize;
var query = _context.TaskChatMessages
.Where(m => m.TaskId == request.TaskId && !m.IsDeleted)
.Include(m => m.ReplyToMessage)
.OrderBy(m => m.CreationDate);
var totalCount = await query.CountAsync(cancellationToken);
var messages = await query
.Skip(skip)
.Take(request.PageSize)
.ToListAsync(cancellationToken);
// ✅ گرفتن تمامی کاربران برای نمایش نام کامل فرستنده به جای "کاربر"
var senderUserIds = messages.Select(m => m.SenderUserId).Distinct().ToList();
var users = await _context.Users
.Where(u => senderUserIds.Contains(u.Id))
.ToDictionaryAsync(u => u.Id, u => u.FullName, cancellationToken);
// ✅ گرفتن تمامی زمان‌های اضافی (Additional Times) برای نمایش به صورت نوت
var taskSections = await _context.TaskSections
.Where(ts => ts.TaskId == request.TaskId)
.Include(ts => ts.AdditionalTimes)
.ToListAsync(cancellationToken);
// ✅ تمام زمان‌های اضافی را یکجا بگیر و مرتب کن
var allAdditionalTimes = taskSections
.SelectMany(ts => ts.AdditionalTimes)
.OrderBy(at => at.CreationDate)
.ToList();
var messageDtos = new List<MessageDto>();
// ✅ ابتدا زمان‌های اضافی قبل از اولین پیام را اضافه کن (اگر پیامی وجود داشته باشد)
if (messages.Any())
{
var firstMessageDate = messages.First().CreationDate;
var additionalTimesBeforeFirstMessage = allAdditionalTimes
.Where(at => at.CreationDate < firstMessageDate)
.ToList();
messageDtos.AddRange(CreateAdditionalTimeNotes(additionalTimesBeforeFirstMessage, users, request.TaskId));
}
else
{
// ✅ اگر هیچ پیامی وجود ندارد، همه زمان‌های اضافی را نمایش بده
messageDtos.AddRange(CreateAdditionalTimeNotes(allAdditionalTimes, users, request.TaskId));
}
foreach (var message in messages)
{
// ✅ نام فرستنده را از Dictionary Users بگیر، در صورت عدم وجود "کاربر ناشناس" نمایش بده
var senderName = users.GetValueOrDefault(message.SenderUserId, "کاربر ناشناس");
var dto = new MessageDto
{
Id = message.Id,
TaskId = message.TaskId,
SenderUserId = message.SenderUserId,
SenderName = senderName,
MessageType = message.MessageType.ToString(),
TextContent = message.TextContent,
ReplyToMessageId = message.ReplyToMessageId,
IsEdited = message.IsEdited,
EditedDate = message.EditedDate,
IsPinned = message.IsPinned,
PinnedDate = message.PinnedDate,
PinnedByUserId = message.PinnedByUserId,
CreationDate = message.CreationDate,
IsMine = message.SenderUserId == currentUserId
};
if (message.ReplyToMessage != null)
{
var replySenderName = users.GetValueOrDefault(message.ReplyToMessage.SenderUserId, "کاربر ناشناس");
dto.ReplyToMessage = new MessageDto
{
Id = message.ReplyToMessage.Id,
SenderUserId = message.ReplyToMessage.SenderUserId,
SenderName = replySenderName,
TextContent = message.ReplyToMessage.TextContent,
CreationDate = message.ReplyToMessage.CreationDate
};
}
if (message.FileId.HasValue)
{
var file = await _context.UploadedFiles.FirstOrDefaultAsync(f => f.Id == message.FileId.Value, cancellationToken);
if (file != null)
{
dto.File = new MessageFileDto
{
Id = file.Id,
FileName = file.OriginalFileName,
FileUrl = file.StorageUrl ?? "",
FileSizeBytes = file.FileSizeBytes,
FileType = file.FileType.ToString(),
ThumbnailUrl = file.ThumbnailUrl,
ImageWidth = file.ImageWidth,
ImageHeight = file.ImageHeight,
DurationSeconds = file.DurationSeconds
};
}
}
messageDtos.Add(dto);
// ✅ پیدا کردن پیام بعدی (اگر وجود داشته باشد)
var currentIndex = messages.IndexOf(message);
var nextMessage = currentIndex < messages.Count - 1 ? messages[currentIndex + 1] : null;
if (nextMessage != null)
{
// ✅ زمان‌های اضافی بین این پیام و پیام بعدی
var additionalTimesBetween = allAdditionalTimes
.Where(at => at.CreationDate > message.CreationDate && at.CreationDate < nextMessage.CreationDate)
.ToList();
messageDtos.AddRange(CreateAdditionalTimeNotes(additionalTimesBetween, users, request.TaskId));
}
else
{
// ✅ این آخرین پیام است، زمان‌های اضافی بعد از آن را اضافه کن
var additionalTimesAfterLastMessage = allAdditionalTimes
.Where(at => at.CreationDate > message.CreationDate)
.ToList();
messageDtos.AddRange(CreateAdditionalTimeNotes(additionalTimesAfterLastMessage, users, request.TaskId));
}
}
// ✅ مرتب کردن نهایی تمام پیام‌ها (معمولی + نوت‌ها) بر اساس زمان ایجاد
messageDtos = messageDtos.OrderBy(m => m.CreationDate).ToList();
var response = new PaginationResult<MessageDto>()
{
List = messageDtos,
TotalCount = totalCount,
};
return OperationResult<PaginationResult<MessageDto>>.Success(response);
}
}

View File

@@ -0,0 +1,82 @@
using GozareshgirProgramManager.Application._Common.Interfaces;
using GozareshgirProgramManager.Application._Common.Models;
using GozareshgirProgramManager.Application.Modules.TaskChat.DTOs;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace GozareshgirProgramManager.Application.Modules.TaskChat.Queries.GetPinnedMessages;
public record GetPinnedMessagesQuery(Guid TaskId) : IBaseQuery<List<MessageDto>>;
public class GetPinnedMessagesQueryHandler : IBaseQueryHandler<GetPinnedMessagesQuery, List<MessageDto>>
{
private readonly IProgramManagerDbContext _context;
private readonly IAuthHelper _authHelper;
public GetPinnedMessagesQueryHandler(IProgramManagerDbContext context, IAuthHelper authHelper)
{
_context = context;
_authHelper = authHelper;
}
public async Task<OperationResult<List<MessageDto>>> Handle(GetPinnedMessagesQuery request, CancellationToken cancellationToken)
{
var currentUserId = _authHelper.GetCurrentUserId();
var messages = await _context.TaskChatMessages
.Where(m => m.TaskId == request.TaskId && m.IsPinned && !m.IsDeleted)
.Include(m => m.ReplyToMessage)
.OrderByDescending(m => m.PinnedDate)
.ToListAsync(cancellationToken);
// ✅ گرفتن تمامی کاربران برای نمایش نام کامل فرستنده
var senderUserIds = messages.Select(m => m.SenderUserId).Distinct().ToList();
var users = await _context.Users
.Where(u => senderUserIds.Contains(u.Id))
.ToDictionaryAsync(u => u.Id, u => u.FullName, cancellationToken);
var messageDtos = new List<MessageDto>();
foreach (var message in messages)
{
// ✅ نام فرستنده را از User واقعی بگیر (به جای "کاربر" ثابت)
var senderName = users.GetValueOrDefault(message.SenderUserId, "کاربر ناشناس");
var dto = new MessageDto
{
Id = message.Id,
TaskId = message.TaskId,
SenderUserId = message.SenderUserId,
SenderName = senderName,
MessageType = message.MessageType.ToString(),
TextContent = message.TextContent,
IsPinned = message.IsPinned,
PinnedDate = message.PinnedDate,
PinnedByUserId = message.PinnedByUserId,
CreationDate = message.CreationDate,
IsMine = message.SenderUserId == currentUserId
};
if (message.FileId.HasValue)
{
var file = await _context.UploadedFiles.FirstOrDefaultAsync(f => f.Id == message.FileId.Value, cancellationToken);
if (file != null)
{
dto.File = new MessageFileDto
{
Id = file.Id,
FileName = file.OriginalFileName,
FileUrl = file.StorageUrl ?? "",
FileSizeBytes = file.FileSizeBytes,
FileType = file.FileType.ToString(),
ThumbnailUrl = file.ThumbnailUrl
};
}
}
messageDtos.Add(dto);
}
return OperationResult<List<MessageDto>>.Success(messageDtos);
}
}

View File

@@ -0,0 +1,72 @@
using GozareshgirProgramManager.Application._Common.Interfaces;
using GozareshgirProgramManager.Application._Common.Models;
using GozareshgirProgramManager.Application.Modules.TaskChat.DTOs;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace GozareshgirProgramManager.Application.Modules.TaskChat.Queries.SearchMessages;
public record SearchMessagesQuery(
Guid TaskId,
string SearchText,
int Page = 1,
int PageSize = 20
) : IBaseQuery<List<MessageDto>>;
public class SearchMessagesQueryHandler : IBaseQueryHandler<SearchMessagesQuery, List<MessageDto>>
{
private readonly IProgramManagerDbContext _context;
private readonly IAuthHelper _authHelper;
public SearchMessagesQueryHandler(IProgramManagerDbContext context, IAuthHelper authHelper)
{
_context = context;
_authHelper = authHelper;
}
public async Task<OperationResult<List<MessageDto>>> Handle(SearchMessagesQuery request, CancellationToken cancellationToken)
{
var currentUserId = _authHelper.GetCurrentUserId();
var skip = (request.Page - 1) * request.PageSize;
var messages = await _context.TaskChatMessages
.Where(m => m.TaskId == request.TaskId &&
m.TextContent != null &&
m.TextContent.Contains(request.SearchText) &&
!m.IsDeleted)
.Include(m => m.ReplyToMessage)
.OrderByDescending(m => m.CreationDate)
.Skip(skip)
.Take(request.PageSize)
.ToListAsync(cancellationToken);
// ✅ گرفتن تمامی کاربران برای نمایش نام کامل فرستنده
var senderUserIds = messages.Select(m => m.SenderUserId).Distinct().ToList();
var users = await _context.Users
.Where(u => senderUserIds.Contains(u.Id))
.ToDictionaryAsync(u => u.Id, u => u.FullName, cancellationToken);
var messageDtos = new List<MessageDto>();
foreach (var message in messages)
{
// ✅ نام فرستنده را از User واقعی بگیر
var senderName = users.GetValueOrDefault(message.SenderUserId, "کاربر ناشناس");
var dto = new MessageDto
{
Id = message.Id,
TaskId = message.TaskId,
SenderUserId = message.SenderUserId,
SenderName = senderName,
MessageType = message.MessageType.ToString(),
TextContent = message.TextContent,
CreationDate = message.CreationDate,
IsMine = message.SenderUserId == currentUserId
};
messageDtos.Add(dto);
}
return OperationResult<List<MessageDto>>.Success(messageDtos);
}
}

View File

@@ -0,0 +1,38 @@
using GozareshgirProgramManager.Domain.FileManagementAgg.Entities;
namespace GozareshgirProgramManager.Application.Services.FileManagement;
/// <summary>
/// سرویس ذخیره‌سازی فایل
/// </summary>
public interface IFileStorageService
{
/// <summary>
/// آپلود فایل
/// </summary>
Task<(string StoragePath, string StorageUrl)> UploadAsync(
Stream fileStream,
string uniqueFileName,
string category);
/// <summary>
/// حذف فایل
/// </summary>
Task DeleteAsync(string storagePath);
/// <summary>
/// دریافت فایل
/// </summary>
Task<Stream?> GetFileStreamAsync(string storagePath);
/// <summary>
/// بررسی وجود فایل
/// </summary>
Task<bool> ExistsAsync(string storagePath);
/// <summary>
/// دریافت URL فایل
/// </summary>
string GetFileUrl(string storagePath);
}

View File

@@ -0,0 +1,34 @@
namespace GozareshgirProgramManager.Application.Services.FileManagement;
/// <summary>
/// سرویس تولید thumbnail برای تصاویر و ویدیوها
/// </summary>
public interface IThumbnailGeneratorService
{
/// <summary>
/// تولید thumbnail برای تصویر
/// </summary>
Task<(string ThumbnailPath, string ThumbnailUrl)?> GenerateImageThumbnailAsync(
string imagePath,
string category,
int width = 200,
int height = 200);
/// <summary>
/// تولید thumbnail برای ویدیو
/// </summary>
Task<(string ThumbnailPath, string ThumbnailUrl)?> GenerateVideoThumbnailAsync(
string videoPath,
string category);
/// <summary>
/// حذف thumbnail
/// </summary>
Task DeleteThumbnailAsync(string thumbnailPath);
/// <summary>
/// دریافت ابعاد تصویر
/// </summary>
Task<(int Width, int Height)?> GetImageDimensionsAsync(string imagePath);
}

View File

@@ -7,6 +7,8 @@ using GozareshgirProgramManager.Domain.SalaryPaymentSettingAgg.Entities;
using GozareshgirProgramManager.Domain.SkillAgg.Entities;
using GozareshgirProgramManager.Domain.UserAgg.Entities;
using Microsoft.EntityFrameworkCore;
using GozareshgirProgramManager.Domain.TaskChatAgg.Entities;
using GozareshgirProgramManager.Domain.FileManagementAgg.Entities;
namespace GozareshgirProgramManager.Application._Common.Interfaces;
@@ -26,6 +28,9 @@ public interface IProgramManagerDbContext
DbSet<ProjectTask> ProjectTasks { get; set; }
DbSet<TaskChatMessage> TaskChatMessages { get; set; }
DbSet<UploadedFile> UploadedFiles { get; set; }
DbSet<Skill> Skills { get; set; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,244 @@
using GozareshgirProgramManager.Domain._Common;
using GozareshgirProgramManager.Domain._Common.Exceptions;
using GozareshgirProgramManager.Domain.FileManagementAgg.Enums;
using GozareshgirProgramManager.Domain.FileManagementAgg.Events;
using FileType = GozareshgirProgramManager.Domain.FileManagementAgg.Enums.FileType;
namespace GozareshgirProgramManager.Domain.FileManagementAgg.Entities;
/// <summary>
/// فایل آپلود شده - Aggregate Root
/// مدیریت مرکزی تمام فایل‌های سیستم
/// </summary>
public class UploadedFile : EntityBase<Guid>
{
private UploadedFile()
{
}
public UploadedFile(
string originalFileName,
long fileSizeBytes,
string mimeType,
FileType fileType,
FileCategory category,
long uploadedByUserId,
StorageProvider storageProvider = StorageProvider.LocalFileSystem)
{
OriginalFileName = originalFileName;
FileSizeBytes = fileSizeBytes;
MimeType = mimeType;
FileType = fileType;
Category = category;
UploadedByUserId = uploadedByUserId;
UploadDate = DateTime.Now;
StorageProvider = storageProvider;
Status = FileStatus.Uploading;
// Generate unique file name
FileExtension = Path.GetExtension(originalFileName);
UniqueFileName = $"{Guid.NewGuid()}{FileExtension}";
ValidateFile();
AddDomainEvent(new FileUploadStartedEvent(Id, originalFileName, uploadedByUserId));
}
// اطلاعات فایل
public string OriginalFileName { get; private set; } = string.Empty;
public string UniqueFileName { get; private set; } = string.Empty;
public string FileExtension { get; private set; } = string.Empty;
public long FileSizeBytes { get; private set; }
public string MimeType { get; private set; } = string.Empty;
public FileType FileType { get; private set; }
public FileCategory Category { get; private set; }
// ذخیره‌سازی
public StorageProvider StorageProvider { get; private set; }
public string? StoragePath { get; private set; }
public string? StorageUrl { get; private set; }
public string? ThumbnailUrl { get; private set; }
// متادیتا
public long UploadedByUserId { get; private set; }
public DateTime UploadDate { get; private set; }
public FileStatus Status { get; private set; }
// اطلاعات تصویر (اختیاری - برای Image)
public int? ImageWidth { get; private set; }
public int? ImageHeight { get; private set; }
// اطلاعات صوت/ویدیو (اختیاری)
public int? DurationSeconds { get; private set; }
// امنیت
public DateTime? VirusScanDate { get; private set; }
public bool? IsVirusScanPassed { get; private set; }
public string? VirusScanResult { get; private set; }
// Soft Delete
public bool IsDeleted { get; private set; }
public DateTime? DeletedDate { get; private set; }
public long? DeletedByUserId { get; private set; }
// Reference tracking (چه entityهایی از این فایل استفاده می‌کنند)
public string? ReferenceEntityType { get; private set; }
public string? ReferenceEntityId { get; private set; }
private void ValidateFile()
{
if (string.IsNullOrWhiteSpace(OriginalFileName))
{
throw new BadRequestException("نام فایل نمی‌تواند خالی باشد");
}
if (FileSizeBytes <= 0)
{
throw new BadRequestException("حجم فایل باید بیشتر از صفر باشد");
}
if (string.IsNullOrWhiteSpace(MimeType))
{
throw new BadRequestException("نوع MIME فایل باید مشخص شود");
}
// محدودیت حجم (مثلاً 100MB)
const long maxSizeBytes = 100 * 1024 * 1024; // 100MB
if (FileSizeBytes > maxSizeBytes)
{
throw new BadRequestException($"حجم فایل نباید بیشتر از {maxSizeBytes / (1024 * 1024)} مگابایت باشد");
}
}
public void CompleteUpload(string storagePath, string storageUrl)
{
if (Status != FileStatus.Uploading)
{
throw new BadRequestException("فایل قبلاً آپلود شده است");
}
if (string.IsNullOrWhiteSpace(storagePath))
{
throw new BadRequestException("مسیر ذخیره‌سازی نمی‌تواند خالی باشد");
}
if (string.IsNullOrWhiteSpace(storageUrl))
{
throw new BadRequestException("URL فایل نمی‌تواند خالی باشد");
}
StoragePath = storagePath;
StorageUrl = storageUrl;
Status = FileStatus.Active;
AddDomainEvent(new FileUploadCompletedEvent(Id, OriginalFileName, StorageUrl, UploadedByUserId));
}
public void SetThumbnail(string thumbnailUrl)
{
if (FileType != FileType.Image && FileType != FileType.Video)
{
throw new BadRequestException("فقط می‌توان برای تصاویر و ویدیوها thumbnail تنظیم کرد");
}
ThumbnailUrl = thumbnailUrl;
}
public void SetImageDimensions(int width, int height)
{
if (FileType != FileType.Image)
{
throw new BadRequestException("فقط می‌توان برای تصاویر ابعاد تنظیم کرد");
}
if (width <= 0 || height <= 0)
{
throw new BadRequestException("ابعاد تصویر باید بیشتر از صفر باشد");
}
ImageWidth = width;
ImageHeight = height;
}
public void SetDuration(int durationSeconds)
{
if (FileType != FileType.Audio && FileType != FileType.Video)
{
throw new BadRequestException("فقط می‌توان برای فایل‌های صوتی و تصویری مدت زمان تنظیم کرد");
}
if (durationSeconds <= 0)
{
throw new BadRequestException("مدت زمان باید بیشتر از صفر باشد");
}
DurationSeconds = durationSeconds;
}
public void MarkAsDeleted(long deletedByUserId)
{
if (IsDeleted)
{
throw new BadRequestException("فایل قبلاً حذف شده است");
}
IsDeleted = true;
DeletedDate = DateTime.Now;
DeletedByUserId = deletedByUserId;
Status = FileStatus.Deleted;
AddDomainEvent(new FileDeletedEvent(Id, OriginalFileName, deletedByUserId));
}
public void SetReference(string entityType, string entityId)
{
ReferenceEntityType = entityType;
ReferenceEntityId = entityId;
}
public bool IsImage()
{
return FileType == FileType.Image;
}
public bool IsVideo()
{
return FileType == FileType.Video;
}
public bool IsAudio()
{
return FileType == FileType.Audio;
}
public bool IsDocument()
{
return FileType == FileType.Document;
}
public bool IsUploadedBy(long userId)
{
return UploadedByUserId == userId;
}
public bool IsActive()
{
return Status == FileStatus.Active && !IsDeleted;
}
public string GetFileSizeFormatted()
{
const long kb = 1024;
const long mb = kb * 1024;
const long gb = mb * 1024;
if (FileSizeBytes >= gb)
return $"{FileSizeBytes / (double)gb:F2} GB";
if (FileSizeBytes >= mb)
return $"{FileSizeBytes / (double)mb:F2} MB";
if (FileSizeBytes >= kb)
return $"{FileSizeBytes / (double)kb:F2} KB";
return $"{FileSizeBytes} Bytes";
}
}

View File

@@ -0,0 +1,15 @@
namespace GozareshgirProgramManager.Domain.FileManagementAgg.Enums;
/// <summary>
/// دسته‌بندی فایل - مشخص می‌کند فایل در کجا استفاده شده
/// </summary>
public enum FileCategory
{
TaskChatMessage = 1, // پیام چت تسک
TaskAttachment = 2, // ضمیمه تسک
ProjectDocument = 3, // مستندات پروژه
UserProfilePhoto = 4, // عکس پروفایل کاربر
Report = 5, // گزارش
Other = 6 // سایر
}

View File

@@ -0,0 +1,13 @@
namespace GozareshgirProgramManager.Domain.FileManagementAgg.Enums;
/// <summary>
/// وضعیت فایل
/// </summary>
public enum FileStatus
{
Uploading = 1, // در حال آپلود
Active = 2, // فعال و قابل استفاده
Deleted = 5, // حذف شده (Soft Delete)
Archived = 6 // آرشیو شده
}

View File

@@ -0,0 +1,15 @@
namespace GozareshgirProgramManager.Domain.FileManagementAgg.Enums;
/// <summary>
/// نوع فایل
/// </summary>
public enum FileType
{
Document = 1, // اسناد (PDF, Word, Excel, etc.)
Image = 2, // تصویر
Video = 3, // ویدیو
Audio = 4, // صوت
Archive = 5, // فایل فشرده (ZIP, RAR)
Other = 6 // سایر
}

View File

@@ -0,0 +1,10 @@
namespace GozareshgirProgramManager.Domain.FileManagementAgg.Enums;
/// <summary>
/// نوع ذخیره‌ساز فایل
/// </summary>
public enum StorageProvider
{
LocalFileSystem = 1, // دیسک محلی سرور
}

View File

@@ -0,0 +1,36 @@
using GozareshgirProgramManager.Domain._Common;
namespace GozareshgirProgramManager.Domain.FileManagementAgg.Events;
// File Upload Events
public record FileUploadStartedEvent(Guid FileId, string FileName, long UploadedByUserId) : IDomainEvent
{
public DateTime OccurredOn { get; init; } = DateTime.Now;
}
public record FileUploadCompletedEvent(Guid FileId, string FileName, string StorageUrl, long UploadedByUserId) : IDomainEvent
{
public DateTime OccurredOn { get; init; } = DateTime.Now;
}
public record FileDeletedEvent(Guid FileId, string FileName, long DeletedByUserId) : IDomainEvent
{
public DateTime OccurredOn { get; init; } = DateTime.Now;
}
// Virus Scan Events
public record FileQuarantinedEvent(Guid FileId, string FileName) : IDomainEvent
{
public DateTime OccurredOn { get; init; } = DateTime.Now;
}
public record FileVirusScanPassedEvent(Guid FileId, string FileName) : IDomainEvent
{
public DateTime OccurredOn { get; init; } = DateTime.Now;
}
public record FileInfectedEvent(Guid FileId, string FileName, string ScanResult) : IDomainEvent
{
public DateTime OccurredOn { get; init; } = DateTime.Now;
}

View File

@@ -0,0 +1,91 @@
using GozareshgirProgramManager.Domain.FileManagementAgg.Entities;
using GozareshgirProgramManager.Domain.FileManagementAgg.Enums;
namespace GozareshgirProgramManager.Domain.FileManagementAgg.Repositories;
/// <summary>
/// Repository برای مدیریت فایل‌های آپلود شده
/// </summary>
public interface IUploadedFileRepository
{
/// <summary>
/// دریافت فایل بر اساس شناسه
/// </summary>
Task<UploadedFile?> GetByIdAsync(Guid fileId);
/// <summary>
/// دریافت فایل بر اساس نام یکتا
/// </summary>
Task<UploadedFile?> GetByUniqueFileNameAsync(string uniqueFileName);
/// <summary>
/// دریافت لیست فایل‌های یک کاربر
/// </summary>
Task<List<UploadedFile>> GetUserFilesAsync(long userId, int pageNumber, int pageSize);
/// <summary>
/// دریافت فایل‌های یک دسته خاص
/// </summary>
Task<List<UploadedFile>> GetByCategoryAsync(FileCategory category, int pageNumber, int pageSize);
/// <summary>
/// دریافت فایل‌های با وضعیت خاص
/// </summary>
Task<List<UploadedFile>> GetByStatusAsync(FileStatus status, int pageNumber, int pageSize);
/// <summary>
/// دریافت فایل‌های یک Reference خاص
/// </summary>
Task<List<UploadedFile>> GetByReferenceAsync(string entityType, string entityId);
/// <summary>
/// جستجو در فایل‌ها بر اساس نام
/// </summary>
Task<List<UploadedFile>> SearchByNameAsync(string searchTerm, int pageNumber, int pageSize);
/// <summary>
/// دریافت تعداد کل فایل‌های یک کاربر
/// </summary>
Task<int> GetUserFilesCountAsync(long userId);
/// <summary>
/// دریافت مجموع حجم فایل‌های یک کاربر (به بایت)
/// </summary>
Task<long> GetUserTotalFileSizeAsync(long userId);
/// <summary>
/// دریافت فایل‌های منقضی شده برای پاک‌سازی
/// </summary>
Task<List<UploadedFile>> GetExpiredFilesAsync(DateTime olderThan);
/// <summary>
/// اضافه کردن فایل جدید
/// </summary>
Task<UploadedFile> AddAsync(UploadedFile file);
/// <summary>
/// به‌روزرسانی فایل
/// </summary>
Task UpdateAsync(UploadedFile file);
/// <summary>
/// حذف فیزیکی فایل (فقط برای cleanup)
/// </summary>
Task DeleteAsync(UploadedFile file);
/// <summary>
/// ذخیره تغییرات
/// </summary>
Task<int> SaveChangesAsync();
/// <summary>
/// بررسی وجود فایل
/// </summary>
Task<bool> ExistsAsync(Guid fileId);
/// <summary>
/// بررسی وجود فایل با نام یکتا
/// </summary>
Task<bool> ExistsByUniqueFileNameAsync(string uniqueFileName);
}

View File

@@ -20,7 +20,7 @@ public class ProjectTask : ProjectHierarchyNode
{
PhaseId = phaseId;
_sections = new List<TaskSection>();
Priority = TaskPriority.Medium;
Priority = ProjectTaskPriority.Medium;
AddDomainEvent(new TaskCreatedEvent(Id, phaseId, name));
}
@@ -30,7 +30,7 @@ public class ProjectTask : ProjectHierarchyNode
// Task-specific properties
public Enums.TaskStatus Status { get; private set; } = Enums.TaskStatus.NotStarted;
public TaskPriority Priority { get; private set; }
public ProjectTaskPriority Priority { get; private set; }
public DateTime? StartDate { get; private set; }
public DateTime? EndDate { get; private set; }
public DateTime? DueDate { get; private set; }
@@ -119,7 +119,7 @@ public class ProjectTask : ProjectHierarchyNode
AddDomainEvent(new TaskStatusUpdatedEvent(Id, status));
}
public void SetPriority(TaskPriority priority)
public void SetPriority(ProjectTaskPriority priority)
{
Priority = priority;
AddDomainEvent(new TaskPriorityUpdatedEvent(Id, priority));

View File

@@ -270,7 +270,7 @@ public class TaskSection : EntityBase<Guid>
// متوقف کردن فعالیت با EndDate دقیق شده
activeActivity.StopWorkWithSpecificTime(adjustedEndDate, "متوقف خودکار - بیش از تایم تعیین شده");
UpdateStatus(TaskSectionStatus.Incomplete);
UpdateStatus(TaskSectionStatus.PendingForCompletion);
}
}
}

View File

@@ -3,7 +3,7 @@ namespace GozareshgirProgramManager.Domain.ProjectAgg.Enums;
/// <summary>
/// اولویت تسک
/// </summary>
public enum TaskPriority
public enum ProjectTaskPriority
{
/// <summary>
/// پایین

View File

@@ -78,7 +78,7 @@ public record TaskStatusUpdatedEvent(Guid TaskId, TaskStatus Status) : IDomainEv
public DateTime OccurredOn { get; init; } = DateTime.Now;
}
public record TaskPriorityUpdatedEvent(Guid TaskId, TaskPriority Priority) : IDomainEvent
public record TaskPriorityUpdatedEvent(Guid TaskId, ProjectTaskPriority Priority) : IDomainEvent
{
public DateTime OccurredOn { get; init; } = DateTime.Now;
}

View File

@@ -36,7 +36,7 @@ public interface IProjectTaskRepository : IRepository<Guid, ProjectTask>
/// <summary>
/// Get tasks by priority
/// </summary>
Task<List<ProjectTask>> GetByPriorityAsync(ProjectAgg.Enums.TaskPriority priority);
Task<List<ProjectTask>> GetByPriorityAsync(ProjectAgg.Enums.ProjectTaskPriority priority);
/// <summary>
/// Get tasks assigned to user

View File

@@ -14,4 +14,7 @@ public interface ITaskSectionRepository: IRepository<Guid,TaskSection>
Task<List<TaskSection>> GetAssignedToUserAsync(long userId);
Task<List<TaskSection>> GetActiveSectionsIncludeAllAsync(CancellationToken cancellationToken);
Task<bool> HasUserAnyInProgressSectionAsync(long userId, CancellationToken cancellationToken = default);
// جدید: دریافت سکشن‌هایی که هنوز Completed یا PendingForCompletion نشده‌اند با اطلاعات کامل
Task<List<TaskSection>> GetAllNotCompletedOrPendingIncludeAllAsync(CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,183 @@
using GozareshgirProgramManager.Domain._Common;
using GozareshgirProgramManager.Domain._Common.Exceptions;
using GozareshgirProgramManager.Domain.TaskChatAgg.Events;
using MessageType = GozareshgirProgramManager.Domain.TaskChatAgg.Enums.MessageType;
namespace GozareshgirProgramManager.Domain.TaskChatAgg.Entities;
/// <summary>
/// پیام چت تسک - Aggregate Root
/// هر کسی که به تسک دسترسی داشته باشد می‌تواند پیام‌ها را ببیند و ارسال کند
/// نیازی به مدیریت گروه و ممبر نیست چون دسترسی از طریق خود تسک کنترل می‌شود
/// </summary>
public class TaskChatMessage : EntityBase<Guid>
{
private TaskChatMessage()
{
}
public TaskChatMessage(Guid taskId, long senderUserId, MessageType messageType,
string? textContent = null,Guid? fileId = null)
{
TaskId = taskId;
SenderUserId = senderUserId;
MessageType = messageType;
TextContent = textContent;
IsEdited = false;
IsDeleted = false;
IsPinned = false;
if (fileId.HasValue)
{
SetFile(fileId.Value);
}
ValidateMessage();
AddDomainEvent(new TaskChatMessageSentEvent(Id, taskId, senderUserId, messageType));
}
// Reference به Task (Foreign Key فقط - بدون Navigation Property برای جلوگیری از coupling)
public Guid TaskId { get; private set; }
public long SenderUserId { get; private set; }
public MessageType MessageType { get; private set; }
// محتوای متنی (برای پیام‌های Text و Caption برای فایل/تصویر)
public string? TextContent { get; private set; }
// ارجاع به فایل (برای پیام‌های File, Voice, Image, Video)
public Guid? FileId { get; private set; }
// پیام Reply
public Guid? ReplyToMessageId { get; private set; }
public TaskChatMessage? ReplyToMessage { get; private set; }
// وضعیت پیام
public bool IsEdited { get; private set; }
public DateTime? EditedDate { get; private set; }
public bool IsDeleted { get; private set; }
public DateTime? DeletedDate { get; private set; }
public bool IsPinned { get; private set; }
public DateTime? PinnedDate { get; private set; }
public long? PinnedByUserId { get; private set; }
private void ValidateMessage()
{
// ✅ بررسی پیام‌های متنی
if (MessageType == MessageType.Text && string.IsNullOrWhiteSpace(TextContent))
{
throw new BadRequestException("پیام متنی نمی‌تواند خالی باشد");
}
// ✅ بررسی پیام‌های فایلی - باید FileId داشته باشند
if ((MessageType == MessageType.File || MessageType == MessageType.Voice ||
MessageType == MessageType.Image || MessageType == MessageType.Video)
&& FileId == null)
{
throw new BadRequestException("پیام‌های فایلی باید شناسه فایل داشته باشند");
}
// ✅ بررسی یادداشت‌های سیستم - باید محتوای متنی داشته باشند
if (MessageType == MessageType.Note && string.IsNullOrWhiteSpace(TextContent))
{
throw new BadRequestException("یادداشت نمی‌تواند خالی باشد");
}
}
public void SetFile(Guid fileId)
{
if (MessageType != MessageType.File && MessageType != MessageType.Image &&
MessageType != MessageType.Video && MessageType != MessageType.Voice)
{
throw new BadRequestException("فقط می‌توان برای پیام‌های فایل، تصویر، ویدیو و صدا شناسه فایل تنظیم کرد");
}
FileId = fileId;
}
public void EditMessage(string newTextContent, long editorUserId)
{
if (IsDeleted)
{
throw new BadRequestException("نمی‌توان پیام حذف شده را ویرایش کرد");
}
if (editorUserId != SenderUserId)
{
throw new BadRequestException("فقط فرستنده می‌تواند پیام را ویرایش کند");
}
if ((MessageType != MessageType.Text && !string.IsNullOrWhiteSpace(TextContent)))
{
throw new BadRequestException("فقط پیام‌های متنی قابل ویرایش هستند");
}
if (string.IsNullOrWhiteSpace(newTextContent))
{
throw new BadRequestException("محتوای پیام نمی‌تواند خالی باشد");
}
TextContent = newTextContent;
IsEdited = true;
EditedDate = DateTime.Now;
AddDomainEvent(new TaskChatMessageEditedEvent(Id, TaskId, editorUserId));
}
public void DeleteMessage(long deleterUserId)
{
if (IsDeleted)
{
throw new BadRequestException("پیام قبلاً حذف شده است");
}
if (deleterUserId != SenderUserId)
{
throw new BadRequestException("فقط فرستنده می‌تواند پیام را حذف کند");
}
IsDeleted = true;
DeletedDate = DateTime.Now;
AddDomainEvent(new TaskChatMessageDeletedEvent(Id, TaskId, deleterUserId));
}
public void PinMessage(long pinnerUserId)
{
if (IsDeleted)
{
throw new BadRequestException("نمی‌توان پیام حذف شده را پین کرد");
}
if (IsPinned)
{
throw new BadRequestException("این پیام قبلاً پین شده است");
}
IsPinned = true;
PinnedDate = DateTime.Now;
PinnedByUserId = pinnerUserId;
AddDomainEvent(new TaskChatMessagePinnedEvent(Id, TaskId, pinnerUserId));
}
public void UnpinMessage(long unpinnerUserId)
{
if (!IsPinned)
{
throw new BadRequestException("این پیام پین نشده است");
}
IsPinned = false;
PinnedDate = null;
PinnedByUserId = null;
AddDomainEvent(new TaskChatMessageUnpinnedEvent(Id, TaskId, unpinnerUserId));
}
public void SetReplyTo(Guid replyToMessageId)
{
ReplyToMessageId = replyToMessageId;
}
public bool IsSentBy(long userId)
{
return SenderUserId == userId;
}
}

View File

@@ -0,0 +1,15 @@
namespace GozareshgirProgramManager.Domain.TaskChatAgg.Enums;
/// <summary>
/// نوع پیام در چت تسک
/// </summary>
public enum MessageType
{
Text = 1, // پیام متنی
File = 2, // فایل (اسناد، PDF، و غیره)
Image = 3, // تصویر
Voice = 4, // پیام صوتی
Video = 5, // ویدیو
Note = 6, // ✅ یادداشت سیستم (برای زمان اضافی و اطلاعات خودکار)
}

View File

@@ -0,0 +1,31 @@
using GozareshgirProgramManager.Domain._Common;
using GozareshgirProgramManager.Domain.TaskChatAgg.Enums;
namespace GozareshgirProgramManager.Domain.TaskChatAgg.Events;
// Message Events
public record TaskChatMessageSentEvent(Guid MessageId, Guid TaskId, long SenderUserId, MessageType MessageType) : IDomainEvent
{
public DateTime OccurredOn { get; init; } = DateTime.Now;
}
public record TaskChatMessageEditedEvent(Guid MessageId, Guid TaskId, long EditorUserId) : IDomainEvent
{
public DateTime OccurredOn { get; init; } = DateTime.Now;
}
public record TaskChatMessageDeletedEvent(Guid MessageId, Guid TaskId, long DeleterUserId) : IDomainEvent
{
public DateTime OccurredOn { get; init; } = DateTime.Now;
}
public record TaskChatMessagePinnedEvent(Guid MessageId, Guid TaskId, long PinnerUserId) : IDomainEvent
{
public DateTime OccurredOn { get; init; } = DateTime.Now;
}
public record TaskChatMessageUnpinnedEvent(Guid MessageId, Guid TaskId, long UnpinnerUserId) : IDomainEvent
{
public DateTime OccurredOn { get; init; } = DateTime.Now;
}

View File

@@ -0,0 +1,75 @@
using GozareshgirProgramManager.Domain.TaskChatAgg.Entities;
namespace GozareshgirProgramManager.Domain.TaskChatAgg.Repositories;
/// <summary>
/// Repository برای مدیریت پیام‌های چت تسک
/// </summary>
public interface ITaskChatMessageRepository
{
/// <summary>
/// دریافت پیام بر اساس شناسه
/// </summary>
Task<TaskChatMessage?> GetByIdAsync(Guid messageId);
/// <summary>
/// دریافت لیست پیام‌های یک تسک (با صفحه‌بندی)
/// </summary>
Task<List<TaskChatMessage>> GetTaskMessagesAsync(Guid taskId, int pageNumber, int pageSize);
/// <summary>
/// دریافت تعداد کل پیام‌های یک تسک
/// </summary>
Task<int> GetTaskMessageCountAsync(Guid taskId);
/// <summary>
/// دریافت پیام‌های پین شده یک تسک
/// </summary>
Task<List<TaskChatMessage>> GetPinnedMessagesAsync(Guid taskId);
/// <summary>
/// دریافت آخرین پیام یک تسک
/// </summary>
Task<TaskChatMessage?> GetLastMessageAsync(Guid taskId);
/// <summary>
/// جستجو در پیام‌های یک تسک
/// </summary>
Task<List<TaskChatMessage>> SearchMessagesAsync(Guid taskId, string searchText, int pageNumber, int pageSize);
/// <summary>
/// دریافت پیام‌های یک کاربر خاص در یک تسک
/// </summary>
Task<List<TaskChatMessage>> GetUserMessagesAsync(Guid taskId, long userId, int pageNumber, int pageSize);
/// <summary>
/// دریافت پیام‌های با فایل (تصویر، ویدیو، فایل و...) - پیام‌هایی که FileId دارند
/// </summary>
Task<List<TaskChatMessage>> GetMediaMessagesAsync(Guid taskId, int pageNumber, int pageSize);
/// <summary>
/// اضافه کردن پیام جدید
/// </summary>
Task<TaskChatMessage> AddAsync(TaskChatMessage message);
/// <summary>
/// به‌روزرسانی پیام
/// </summary>
Task UpdateAsync(TaskChatMessage message);
/// <summary>
/// حذف فیزیکی پیام (در صورت نیاز - معمولاً استفاده نمی‌شود)
/// </summary>
Task DeleteAsync(TaskChatMessage message);
/// <summary>
/// ذخیره تغییرات
/// </summary>
Task<int> SaveChangesAsync();
/// <summary>
/// بررسی وجود پیام
/// </summary>
Task<bool> ExistsAsync(Guid messageId);
}

View File

@@ -4,9 +4,11 @@
using FluentValidation;
using GozareshgirProgramManager.Application._Common.Behaviors;
using GozareshgirProgramManager.Application._Common.Interfaces;
using GozareshgirProgramManager.Application.Services.FileManagement;
using GozareshgirProgramManager.Domain._Common;
using GozareshgirProgramManager.Domain.CheckoutAgg.Repositories;
using GozareshgirProgramManager.Domain.CustomerAgg.Repositories;
using GozareshgirProgramManager.Domain.FileManagementAgg.Repositories;
using GozareshgirProgramManager.Domain.ProjectAgg.Repositories;
using GozareshgirProgramManager.Domain.RoleAgg.Repositories;
using GozareshgirProgramManager.Domain.RoleAgg.Repositories;
@@ -14,6 +16,7 @@ using GozareshgirProgramManager.Domain.SalaryPaymentSettingAgg.Repositories;
using GozareshgirProgramManager.Domain.SalaryPaymentSettingAgg.Repositories;
using GozareshgirProgramManager.Domain.SkillAgg.Repositories;
using GozareshgirProgramManager.Domain.SkillAgg.Repositories;
using GozareshgirProgramManager.Domain.TaskChatAgg.Repositories;
using GozareshgirProgramManager.Domain.UserAgg.Repositories;
using GozareshgirProgramManager.Infrastructure.Persistence;
using GozareshgirProgramManager.Infrastructure.Persistence.Context;
@@ -82,6 +85,14 @@ public static class DependencyInjection
services.AddScoped<IUserRefreshTokenRepository, UserRefreshTokenRepository>();
// File Management & Task Chat
services.AddScoped<IUploadedFileRepository, Persistence.Repositories.FileManagement.UploadedFileRepository>();
services.AddScoped<ITaskChatMessageRepository, Persistence.Repositories.TaskChat.TaskChatMessageRepository>();
// File Storage Services
services.AddScoped<IFileStorageService, Services.FileManagement.LocalFileStorageService>();
services.AddScoped<IThumbnailGeneratorService, Services.FileManagement.ThumbnailGeneratorService>();
// JWT Settings
services.Configure<JwtSettings>(configuration.GetSection("JwtSettings"));

View File

@@ -15,11 +15,21 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.1" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
<!--<PackageReference Include="System.Text.Encodings.Web" Version="10.0.0" />-->
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Application\GozareshgirProgramManager.Application\GozareshgirProgramManager.Application.csproj" />
<ProjectReference Include="..\..\Domain\GozareshgirProgramManager.Domain\GozareshgirProgramManager.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<Reference Include="Microsoft.AspNetCore.Hosting.Abstractions">
<HintPath>C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App\10.0.1\Microsoft.AspNetCore.Hosting.Abstractions.dll</HintPath>
</Reference>
<Reference Include="Microsoft.Extensions.Hosting.Abstractions">
<HintPath>C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App\10.0.1\Microsoft.Extensions.Hosting.Abstractions.dll</HintPath>
</Reference>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,158 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GozareshgirProgramManager.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class addtaskchatuploadedfile : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "TaskChatMessages",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
TaskId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
SenderUserId = table.Column<long>(type: "bigint", nullable: false),
MessageType = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
TextContent = table.Column<string>(type: "nvarchar(4000)", maxLength: 4000, nullable: true),
FileId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
ReplyToMessageId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
IsEdited = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
EditedDate = table.Column<DateTime>(type: "datetime2", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
DeletedDate = table.Column<DateTime>(type: "datetime2", nullable: true),
IsPinned = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
PinnedDate = table.Column<DateTime>(type: "datetime2", nullable: true),
PinnedByUserId = table.Column<long>(type: "bigint", nullable: true),
CreationDate = table.Column<DateTime>(type: "datetime2", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_TaskChatMessages", x => x.Id);
table.ForeignKey(
name: "FK_TaskChatMessages_TaskChatMessages_ReplyToMessageId",
column: x => x.ReplyToMessageId,
principalTable: "TaskChatMessages",
principalColumn: "Id");
});
migrationBuilder.CreateTable(
name: "UploadedFiles",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
OriginalFileName = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
UniqueFileName = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
FileExtension = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
FileSizeBytes = table.Column<long>(type: "bigint", nullable: false),
MimeType = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
FileType = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
Category = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
StorageProvider = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
StoragePath = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
StorageUrl = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
ThumbnailUrl = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
UploadedByUserId = table.Column<long>(type: "bigint", nullable: false),
UploadDate = table.Column<DateTime>(type: "datetime2", nullable: false),
Status = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
ImageWidth = table.Column<int>(type: "int", nullable: true),
ImageHeight = table.Column<int>(type: "int", nullable: true),
DurationSeconds = table.Column<int>(type: "int", nullable: true),
VirusScanDate = table.Column<DateTime>(type: "datetime2", nullable: true),
IsVirusScanPassed = table.Column<bool>(type: "bit", nullable: true),
VirusScanResult = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
DeletedDate = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedByUserId = table.Column<long>(type: "bigint", nullable: true),
ReferenceEntityType = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
ReferenceEntityId = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
CreationDate = table.Column<DateTime>(type: "datetime2", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_UploadedFiles", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_TaskChatMessages_CreationDate",
table: "TaskChatMessages",
column: "CreationDate");
migrationBuilder.CreateIndex(
name: "IX_TaskChatMessages_FileId",
table: "TaskChatMessages",
column: "FileId");
migrationBuilder.CreateIndex(
name: "IX_TaskChatMessages_IsDeleted",
table: "TaskChatMessages",
column: "IsDeleted");
migrationBuilder.CreateIndex(
name: "IX_TaskChatMessages_ReplyToMessageId",
table: "TaskChatMessages",
column: "ReplyToMessageId");
migrationBuilder.CreateIndex(
name: "IX_TaskChatMessages_SenderUserId",
table: "TaskChatMessages",
column: "SenderUserId");
migrationBuilder.CreateIndex(
name: "IX_TaskChatMessages_TaskId",
table: "TaskChatMessages",
column: "TaskId");
migrationBuilder.CreateIndex(
name: "IX_TaskChatMessages_TaskId_IsPinned",
table: "TaskChatMessages",
columns: new[] { "TaskId", "IsPinned" });
migrationBuilder.CreateIndex(
name: "IX_UploadedFiles_Category",
table: "UploadedFiles",
column: "Category");
migrationBuilder.CreateIndex(
name: "IX_UploadedFiles_IsDeleted",
table: "UploadedFiles",
column: "IsDeleted");
migrationBuilder.CreateIndex(
name: "IX_UploadedFiles_ReferenceEntityType_ReferenceEntityId",
table: "UploadedFiles",
columns: new[] { "ReferenceEntityType", "ReferenceEntityId" });
migrationBuilder.CreateIndex(
name: "IX_UploadedFiles_Status",
table: "UploadedFiles",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_UploadedFiles_UniqueFileName",
table: "UploadedFiles",
column: "UniqueFileName",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_UploadedFiles_UploadedByUserId",
table: "UploadedFiles",
column: "UploadedByUserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "TaskChatMessages");
migrationBuilder.DropTable(
name: "UploadedFiles");
}
}
}

View File

@@ -102,6 +102,131 @@ namespace GozareshgirProgramManager.Infrastructure.Migrations
b.ToTable("Customers", (string)null);
});
modelBuilder.Entity("GozareshgirProgramManager.Domain.FileManagementAgg.Entities.UploadedFile", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uniqueidentifier");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTime>("CreationDate")
.HasColumnType("datetime2");
b.Property<long?>("DeletedByUserId")
.HasColumnType("bigint");
b.Property<DateTime?>("DeletedDate")
.HasColumnType("datetime2");
b.Property<int?>("DurationSeconds")
.HasColumnType("int");
b.Property<string>("FileExtension")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<long>("FileSizeBytes")
.HasColumnType("bigint");
b.Property<string>("FileType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<int?>("ImageHeight")
.HasColumnType("int");
b.Property<int?>("ImageWidth")
.HasColumnType("int");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<bool?>("IsVirusScanPassed")
.HasColumnType("bit");
b.Property<string>("MimeType")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("OriginalFileName")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("ReferenceEntityId")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("ReferenceEntityType")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("StoragePath")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<string>("StorageProvider")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("StorageUrl")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<string>("ThumbnailUrl")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<string>("UniqueFileName")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<DateTime>("UploadDate")
.HasColumnType("datetime2");
b.Property<long>("UploadedByUserId")
.HasColumnType("bigint");
b.Property<DateTime?>("VirusScanDate")
.HasColumnType("datetime2");
b.Property<string>("VirusScanResult")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.HasKey("Id");
b.HasIndex("Category");
b.HasIndex("IsDeleted");
b.HasIndex("Status");
b.HasIndex("UniqueFileName")
.IsUnique();
b.HasIndex("UploadedByUserId");
b.HasIndex("ReferenceEntityType", "ReferenceEntityId");
b.ToTable("UploadedFiles", (string)null);
});
modelBuilder.Entity("GozareshgirProgramManager.Domain.ProjectAgg.Entities.PhaseSection", b =>
{
b.Property<Guid>("Id")
@@ -495,6 +620,81 @@ namespace GozareshgirProgramManager.Infrastructure.Migrations
b.ToTable("Skills", (string)null);
});
modelBuilder.Entity("GozareshgirProgramManager.Domain.TaskChatAgg.Entities.TaskChatMessage", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreationDate")
.HasColumnType("datetime2");
b.Property<DateTime?>("DeletedDate")
.HasColumnType("datetime2");
b.Property<DateTime?>("EditedDate")
.HasColumnType("datetime2");
b.Property<Guid?>("FileId")
.HasColumnType("uniqueidentifier");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<bool>("IsEdited")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<bool>("IsPinned")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<string>("MessageType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<long?>("PinnedByUserId")
.HasColumnType("bigint");
b.Property<DateTime?>("PinnedDate")
.HasColumnType("datetime2");
b.Property<Guid?>("ReplyToMessageId")
.HasColumnType("uniqueidentifier");
b.Property<long>("SenderUserId")
.HasColumnType("bigint");
b.Property<Guid>("TaskId")
.HasColumnType("uniqueidentifier");
b.Property<string>("TextContent")
.HasMaxLength(4000)
.HasColumnType("nvarchar(4000)");
b.HasKey("Id");
b.HasIndex("CreationDate");
b.HasIndex("FileId");
b.HasIndex("IsDeleted");
b.HasIndex("ReplyToMessageId");
b.HasIndex("SenderUserId");
b.HasIndex("TaskId");
b.HasIndex("TaskId", "IsPinned");
b.ToTable("TaskChatMessages", (string)null);
});
modelBuilder.Entity("GozareshgirProgramManager.Domain.UserAgg.Entities.User", b =>
{
b.Property<long>("Id")
@@ -779,6 +979,16 @@ namespace GozareshgirProgramManager.Infrastructure.Migrations
b.Navigation("WorkingHoursList");
});
modelBuilder.Entity("GozareshgirProgramManager.Domain.TaskChatAgg.Entities.TaskChatMessage", b =>
{
b.HasOne("GozareshgirProgramManager.Domain.TaskChatAgg.Entities.TaskChatMessage", "ReplyToMessage")
.WithMany()
.HasForeignKey("ReplyToMessageId")
.OnDelete(DeleteBehavior.NoAction);
b.Navigation("ReplyToMessage");
});
modelBuilder.Entity("GozareshgirProgramManager.Domain.UserAgg.Entities.User", b =>
{
b.OwnsMany("GozareshgirProgramManager.Domain.RoleUserAgg.RoleUser", "RoleUser", b1 =>

View File

@@ -1,14 +1,16 @@
using GozareshgirProgramManager.Application._Common.Interfaces;
using GozareshgirProgramManager.Application._Common.Interfaces;
using GozareshgirProgramManager.Domain.CheckoutAgg.Entities;
using GozareshgirProgramManager.Domain.CustomerAgg;
using GozareshgirProgramManager.Application._Common.Interfaces;
using GozareshgirProgramManager.Domain.CustomerAgg;
using GozareshgirProgramManager.Domain.FileManagementAgg.Entities;
using GozareshgirProgramManager.Domain.ProjectAgg.Entities;
using GozareshgirProgramManager.Domain.RoleAgg.Entities;
using GozareshgirProgramManager.Domain.RoleUserAgg;
using GozareshgirProgramManager.Domain.SalaryPaymentSettingAgg.Entities;
using GozareshgirProgramManager.Domain.UserAgg.Entities;
using GozareshgirProgramManager.Domain.SkillAgg.Entities;
using GozareshgirProgramManager.Domain.TaskChatAgg.Entities;
using Microsoft.EntityFrameworkCore;
namespace GozareshgirProgramManager.Infrastructure.Persistence.Context;
@@ -40,6 +42,13 @@ public class ProgramManagerDbContext : DbContext, IProgramManagerDbContext
public DbSet<Role> Roles { get; set; } = null!;
public DbSet<Skill> Skills { get; set; } = null!;
// File Management
public DbSet<UploadedFile> UploadedFiles { get; set; } = null!;
// Task Chat
public DbSet<TaskChatMessage> TaskChatMessages { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(ProgramManagerDbContext).Assembly);

View File

@@ -0,0 +1,87 @@
using GozareshgirProgramManager.Domain.TaskChatAgg.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace GozareshgirProgramManager.Infrastructure.Persistence.Mappings;
public class TaskChatMessageMapping : IEntityTypeConfiguration<TaskChatMessage>
{
public void Configure(EntityTypeBuilder<TaskChatMessage> builder)
{
builder.ToTable("TaskChatMessages");
builder.HasKey(x => x.Id);
builder.Property(x => x.Id)
.ValueGeneratedNever();
// Task Reference
builder.Property(x => x.TaskId)
.IsRequired();
builder.HasIndex(x => x.TaskId);
// Sender
builder.Property(x => x.SenderUserId)
.IsRequired();
builder.HasIndex(x => x.SenderUserId);
// Message Type
builder.Property(x => x.MessageType)
.IsRequired()
.HasConversion<string>()
.HasMaxLength(50);
// Content
builder.Property(x => x.TextContent)
.HasMaxLength(4000);
// File Reference
builder.Property(x => x.FileId);
builder.HasIndex(x => x.FileId);
// Reply
builder.Property(x => x.ReplyToMessageId);
builder.HasOne(x => x.ReplyToMessage)
.WithMany()
.HasForeignKey(x => x.ReplyToMessageId)
.OnDelete(DeleteBehavior.NoAction);
// Status
builder.Property(x => x.IsEdited)
.IsRequired()
.HasDefaultValue(false);
builder.Property(x => x.EditedDate);
builder.Property(x => x.IsDeleted)
.IsRequired()
.HasDefaultValue(false);
builder.Property(x => x.DeletedDate);
builder.HasIndex(x => x.IsDeleted);
// Pin
builder.Property(x => x.IsPinned)
.IsRequired()
.HasDefaultValue(false);
builder.Property(x => x.PinnedDate);
builder.Property(x => x.PinnedByUserId);
builder.HasIndex(x => new { x.TaskId, x.IsPinned });
// Audit
builder.Property(x => x.CreationDate)
.IsRequired();
builder.HasIndex(x => x.CreationDate);
// Query Filter - پیام‌های حذف نشده
builder.HasQueryFilter(x => !x.IsDeleted);
}
}

View File

@@ -0,0 +1,121 @@
using GozareshgirProgramManager.Domain.FileManagementAgg.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace GozareshgirProgramManager.Infrastructure.Persistence.Mappings;
public class UploadedFileMapping : IEntityTypeConfiguration<UploadedFile>
{
public void Configure(EntityTypeBuilder<UploadedFile> builder)
{
builder.ToTable("UploadedFiles");
builder.HasKey(x => x.Id);
builder.Property(x => x.Id)
.ValueGeneratedNever();
// اطلاعات فایل
builder.Property(x => x.OriginalFileName)
.IsRequired()
.HasMaxLength(500);
builder.Property(x => x.UniqueFileName)
.IsRequired()
.HasMaxLength(500);
builder.HasIndex(x => x.UniqueFileName)
.IsUnique();
builder.Property(x => x.FileExtension)
.IsRequired()
.HasMaxLength(50);
builder.Property(x => x.FileSizeBytes)
.IsRequired();
builder.Property(x => x.MimeType)
.IsRequired()
.HasMaxLength(200);
builder.Property(x => x.FileType)
.IsRequired()
.HasConversion<string>()
.HasMaxLength(50);
builder.Property(x => x.Category)
.IsRequired()
.HasConversion<string>()
.HasMaxLength(100);
// ذخیره‌سازی
builder.Property(x => x.StorageProvider)
.IsRequired()
.HasConversion<string>()
.HasMaxLength(50);
builder.Property(x => x.StoragePath)
.HasMaxLength(1000);
builder.Property(x => x.StorageUrl)
.HasMaxLength(1000);
builder.Property(x => x.ThumbnailUrl)
.HasMaxLength(1000);
// متادیتا
builder.Property(x => x.UploadedByUserId)
.IsRequired();
builder.Property(x => x.UploadDate)
.IsRequired();
builder.Property(x => x.Status)
.IsRequired()
.HasConversion<string>()
.HasMaxLength(50);
builder.HasIndex(x => x.Status);
builder.HasIndex(x => x.UploadedByUserId);
builder.HasIndex(x => x.Category);
// اطلاعات تصویر
builder.Property(x => x.ImageWidth);
builder.Property(x => x.ImageHeight);
// اطلاعات صوت/ویدیو
builder.Property(x => x.DurationSeconds);
// امنیت
builder.Property(x => x.VirusScanDate);
builder.Property(x => x.IsVirusScanPassed);
builder.Property(x => x.VirusScanResult)
.HasMaxLength(500);
// Soft Delete
builder.Property(x => x.IsDeleted)
.IsRequired()
.HasDefaultValue(false);
builder.Property(x => x.DeletedDate);
builder.Property(x => x.DeletedByUserId);
builder.HasIndex(x => x.IsDeleted);
// Reference Tracking
builder.Property(x => x.ReferenceEntityType)
.HasMaxLength(100);
builder.Property(x => x.ReferenceEntityId)
.HasMaxLength(100);
builder.HasIndex(x => new { x.ReferenceEntityType, x.ReferenceEntityId });
// Audit
builder.Property(x => x.CreationDate)
.IsRequired();
// Query Filter - فایل‌های حذف نشده
builder.HasQueryFilter(x => !x.IsDeleted);
}
}

View File

@@ -0,0 +1,134 @@
using GozareshgirProgramManager.Domain.FileManagementAgg.Entities;
using GozareshgirProgramManager.Domain.FileManagementAgg.Enums;
using GozareshgirProgramManager.Domain.FileManagementAgg.Repositories;
using GozareshgirProgramManager.Infrastructure.Persistence.Context;
using Microsoft.EntityFrameworkCore;
namespace GozareshgirProgramManager.Infrastructure.Persistence.Repositories.FileManagement;
public class UploadedFileRepository : IUploadedFileRepository
{
private readonly ProgramManagerDbContext _context;
public UploadedFileRepository(ProgramManagerDbContext context)
{
_context = context;
}
public async Task<UploadedFile?> GetByIdAsync(Guid fileId)
{
return await _context.UploadedFiles
.FirstOrDefaultAsync(x => x.Id == fileId);
}
public async Task<UploadedFile?> GetByUniqueFileNameAsync(string uniqueFileName)
{
return await _context.UploadedFiles
.FirstOrDefaultAsync(x => x.UniqueFileName == uniqueFileName);
}
public async Task<List<UploadedFile>> GetUserFilesAsync(long userId, int pageNumber, int pageSize)
{
return await _context.UploadedFiles
.Where(x => x.UploadedByUserId == userId)
.OrderByDescending(x => x.UploadDate)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
}
public async Task<List<UploadedFile>> GetByCategoryAsync(FileCategory category, int pageNumber, int pageSize)
{
return await _context.UploadedFiles
.Where(x => x.Category == category)
.OrderByDescending(x => x.UploadDate)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
}
public async Task<List<UploadedFile>> GetByStatusAsync(FileStatus status, int pageNumber, int pageSize)
{
return await _context.UploadedFiles
.Where(x => x.Status == status)
.OrderByDescending(x => x.UploadDate)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
}
public async Task<List<UploadedFile>> GetByReferenceAsync(string entityType, string entityId)
{
return await _context.UploadedFiles
.Where(x => x.ReferenceEntityType == entityType && x.ReferenceEntityId == entityId)
.OrderByDescending(x => x.UploadDate)
.ToListAsync();
}
public async Task<List<UploadedFile>> SearchByNameAsync(string searchTerm, int pageNumber, int pageSize)
{
return await _context.UploadedFiles
.Where(x => x.OriginalFileName.Contains(searchTerm))
.OrderByDescending(x => x.UploadDate)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
}
public async Task<int> GetUserFilesCountAsync(long userId)
{
return await _context.UploadedFiles
.CountAsync(x => x.UploadedByUserId == userId);
}
public async Task<long> GetUserTotalFileSizeAsync(long userId)
{
return await _context.UploadedFiles
.Where(x => x.UploadedByUserId == userId)
.SumAsync(x => x.FileSizeBytes);
}
public async Task<List<UploadedFile>> GetExpiredFilesAsync(DateTime olderThan)
{
return await _context.UploadedFiles
.IgnoreQueryFilters() // Include deleted files
.Where(x => x.IsDeleted && x.DeletedDate < olderThan)
.ToListAsync();
}
public async Task<UploadedFile> AddAsync(UploadedFile file)
{
await _context.UploadedFiles.AddAsync(file);
return file;
}
public Task UpdateAsync(UploadedFile file)
{
_context.UploadedFiles.Update(file);
return Task.CompletedTask;
}
public Task DeleteAsync(UploadedFile file)
{
_context.UploadedFiles.Remove(file);
return Task.CompletedTask;
}
public async Task<int> SaveChangesAsync()
{
return await _context.SaveChangesAsync();
}
public async Task<bool> ExistsAsync(Guid fileId)
{
return await _context.UploadedFiles
.AnyAsync(x => x.Id == fileId);
}
public async Task<bool> ExistsByUniqueFileNameAsync(string uniqueFileName)
{
return await _context.UploadedFiles
.AnyAsync(x => x.UniqueFileName == uniqueFileName);
}
}

View File

@@ -58,7 +58,7 @@ public class ProjectTaskRepository : RepositoryBase<Guid, ProjectTask>, IProject
.ToListAsync();
}
public Task<List<ProjectTask>> GetByPriorityAsync(TaskPriority priority)
public Task<List<ProjectTask>> GetByPriorityAsync(ProjectTaskPriority priority)
{
return _context.ProjectTasks
.Where(t => t.Priority == priority)

View File

@@ -0,0 +1,122 @@
using GozareshgirProgramManager.Domain.TaskChatAgg.Entities;
using GozareshgirProgramManager.Domain.TaskChatAgg.Repositories;
using GozareshgirProgramManager.Infrastructure.Persistence.Context;
using Microsoft.EntityFrameworkCore;
namespace GozareshgirProgramManager.Infrastructure.Persistence.Repositories.TaskChat;
public class TaskChatMessageRepository : ITaskChatMessageRepository
{
private readonly ProgramManagerDbContext _context;
public TaskChatMessageRepository(ProgramManagerDbContext context)
{
_context = context;
}
public async Task<TaskChatMessage?> GetByIdAsync(Guid messageId)
{
return await _context.TaskChatMessages
.Include(x => x.ReplyToMessage)
.FirstOrDefaultAsync(x => x.Id == messageId);
}
public async Task<List<TaskChatMessage>> GetTaskMessagesAsync(Guid taskId, int pageNumber, int pageSize)
{
return await _context.TaskChatMessages
.Where(x => x.TaskId == taskId)
.Include(x => x.ReplyToMessage)
.OrderBy(x => x.CreationDate)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
}
public async Task<int> GetTaskMessageCountAsync(Guid taskId)
{
return await _context.TaskChatMessages
.CountAsync(x => x.TaskId == taskId);
}
public async Task<List<TaskChatMessage>> GetPinnedMessagesAsync(Guid taskId)
{
return await _context.TaskChatMessages
.Where(x => x.TaskId == taskId && x.IsPinned)
.Include(x => x.ReplyToMessage)
.OrderByDescending(x => x.PinnedDate)
.ToListAsync();
}
public async Task<TaskChatMessage?> GetLastMessageAsync(Guid taskId)
{
return await _context.TaskChatMessages
.Where(x => x.TaskId == taskId)
.OrderByDescending(x => x.CreationDate)
.FirstOrDefaultAsync();
}
public async Task<List<TaskChatMessage>> SearchMessagesAsync(Guid taskId, string searchText, int pageNumber, int pageSize)
{
return await _context.TaskChatMessages
.Where(x => x.TaskId == taskId &&
x.TextContent != null &&
x.TextContent.Contains(searchText))
.Include(x => x.ReplyToMessage)
.OrderByDescending(x => x.CreationDate)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
}
public async Task<List<TaskChatMessage>> GetUserMessagesAsync(Guid taskId, long userId, int pageNumber, int pageSize)
{
return await _context.TaskChatMessages
.Where(x => x.TaskId == taskId && x.SenderUserId == userId)
.Include(x => x.ReplyToMessage)
.OrderByDescending(x => x.CreationDate)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
}
public async Task<List<TaskChatMessage>> GetMediaMessagesAsync(Guid taskId, int pageNumber, int pageSize)
{
return await _context.TaskChatMessages
.Where(x => x.TaskId == taskId && x.FileId != null)
.Include(x => x.ReplyToMessage)
.OrderByDescending(x => x.CreationDate)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
}
public async Task<TaskChatMessage> AddAsync(TaskChatMessage message)
{
await _context.TaskChatMessages.AddAsync(message);
return message;
}
public Task UpdateAsync(TaskChatMessage message)
{
_context.TaskChatMessages.Update(message);
return Task.CompletedTask;
}
public Task DeleteAsync(TaskChatMessage message)
{
_context.TaskChatMessages.Remove(message);
return Task.CompletedTask;
}
public async Task<int> SaveChangesAsync()
{
return await _context.SaveChangesAsync();
}
public async Task<bool> ExistsAsync(Guid messageId)
{
return await _context.TaskChatMessages
.AnyAsync(x => x.Id == messageId);
}
}

View File

@@ -53,4 +53,13 @@ public class TaskSectionRepository:RepositoryBase<Guid,TaskSection>,ITaskSection
.AnyAsync(x => x.CurrentAssignedUserId == userId && x.Status == TaskSectionStatus.InProgress,
cancellationToken);
}
public Task<List<TaskSection>> GetAllNotCompletedOrPendingIncludeAllAsync(CancellationToken cancellationToken)
{
return _context.TaskSections
.Where(x => x.Status != TaskSectionStatus.Completed && x.Status != TaskSectionStatus.PendingForCompletion)
.Include(x => x.Activities)
.Include(x => x.AdditionalTimes)
.ToListAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,108 @@
using GozareshgirProgramManager.Application.Services.FileManagement;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
namespace GozareshgirProgramManager.Infrastructure.Services.FileManagement;
/// <summary>
/// سرویس ذخیره‌سازی فایل در سیستم فایل محلی
/// </summary>
public class LocalFileStorageService : IFileStorageService
{
private readonly string _uploadBasePath;
private readonly string _baseUrl;
public LocalFileStorageService(IConfiguration configuration,
IHttpContextAccessor httpContextAccessor, IHostEnvironment env)
{
// محاسبه مسیر پایه: اگر env نبود، از مسیر فعلی استفاده کن
var contentRoot = env.ContentRootPath;
_uploadBasePath = Path.Combine(contentRoot, "Storage");
// Base URL برای دسترسی به فایل‌ها
var request = httpContextAccessor.HttpContext?.Request;
if (request != null)
{
_baseUrl = $"{request.Scheme}://{request.Host}/storage";
}
else
{
_baseUrl = configuration["FileStorage:BaseUrl"] ?? "http://localhost:5000/storage";
}
// ایجاد پوشه اگر وجود نداشت
if (!Directory.Exists(_uploadBasePath))
{
Directory.CreateDirectory(_uploadBasePath);
}
}
public async Task<(string StoragePath, string StorageUrl)> UploadAsync(
Stream fileStream,
string uniqueFileName,
string category)
{
// ایجاد پوشه دسته‌بندی (مثلاً: Uploads/TaskChatMessage)
var categoryPath = Path.Combine(_uploadBasePath, category);
if (!Directory.Exists(categoryPath))
{
Directory.CreateDirectory(categoryPath);
}
// ایجاد زیرپوشه بر اساس تاریخ (مثلاً: Uploads/TaskChatMessage/2026/01)
var datePath = Path.Combine(categoryPath, DateTime.Now.Year.ToString(),
DateTime.Now.Month.ToString("00"));
if (!Directory.Exists(datePath))
{
Directory.CreateDirectory(datePath);
}
// مسیر کامل فایل
var storagePath = Path.Combine(datePath, uniqueFileName);
// ذخیره فایل
await using var fileStreamOutput = new FileStream(storagePath, FileMode.Create, FileAccess.Write);
await fileStream.CopyToAsync(fileStreamOutput);
// URL فایل
var relativePath = Path.GetRelativePath(_uploadBasePath, storagePath)
.Replace("\\", "/");
var storageUrl = $"{_baseUrl}/{relativePath}";
return (storagePath, storageUrl);
}
public Task DeleteAsync(string storagePath)
{
if (File.Exists(storagePath))
{
File.Delete(storagePath);
}
return Task.CompletedTask;
}
public Task<Stream?> GetFileStreamAsync(string storagePath)
{
if (!File.Exists(storagePath))
{
return Task.FromResult<Stream?>(null);
}
var stream = new FileStream(storagePath, FileMode.Open, FileAccess.Read, FileShare.Read);
return Task.FromResult<Stream?>(stream);
}
public Task<bool> ExistsAsync(string storagePath)
{
return Task.FromResult(File.Exists(storagePath));
}
public string GetFileUrl(string storagePath)
{
var relativePath = Path.GetRelativePath(_uploadBasePath, storagePath)
.Replace("\\", "/");
return $"{_baseUrl}/{relativePath}";
}
}

View File

@@ -0,0 +1,144 @@
using GozareshgirProgramManager.Application.Services.FileManagement;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Processing;
using SixLabors.ImageSharp.Formats.Jpeg;
namespace GozareshgirProgramManager.Infrastructure.Services.FileManagement;
/// <summary>
/// سرویس تولید thumbnail با استفاده از ImageSharp
/// </summary>
public class ThumbnailGeneratorService : IThumbnailGeneratorService
{
private readonly string _thumbnailBasePath;
private readonly string _baseUrl;
public ThumbnailGeneratorService(IConfiguration configuration,
IHttpContextAccessor httpContextAccessor)
{
_thumbnailBasePath = configuration["FileStorage:ThumbnailPath"]
?? Path.Combine(Directory.GetCurrentDirectory(), "storage", "Thumbnails");
var request = httpContextAccessor.HttpContext?.Request;
if (request != null)
{
_baseUrl = $"{request.Scheme}://{request.Host}/storage";
}
else
{
_baseUrl = configuration["FileStorage:BaseUrl"] ?? "http://localhost:5000/storage";
}
if (!Directory.Exists(_thumbnailBasePath))
{
Directory.CreateDirectory(_thumbnailBasePath);
}
}
public async Task<(string ThumbnailPath, string ThumbnailUrl)?> GenerateImageThumbnailAsync(
string imagePath,
string category,
int width = 200,
int height = 200)
{
try
{
if (!File.Exists(imagePath))
{
return null;
}
// بارگذاری تصویر
using var image = await Image.LoadAsync(imagePath);
// Resize با حفظ نسبت
image.Mutate(x => x.Resize(new ResizeOptions
{
Size = new Size(width, height),
Mode = ResizeMode.Max
}));
// نام فایل thumbnail
var fileName = Path.GetFileNameWithoutExtension(imagePath);
var extension = Path.GetExtension(imagePath);
var thumbnailFileName = $"{fileName}_thumb{extension}";
// دریافت مسیر و URL توسط متد private
var (thumbnailPath, thumbnailUrl) = GetThumbnailPathAndUrl(thumbnailFileName, category);
// ذخیره thumbnail با کیفیت 80
await image.SaveAsync(thumbnailPath, new JpegEncoder { Quality = 80 });
return (thumbnailPath, thumbnailUrl);
}
catch (Exception)
{
// در صورت خطا null برمی‌گردانیم
return null;
}
}
public async Task<(string ThumbnailPath, string ThumbnailUrl)?> GenerateVideoThumbnailAsync(string videoPath, string category = "general")
{
// TODO: برای Video thumbnail باید از FFmpeg استفاده کنیم
// فعلاً یک placeholder image برمی‌گردانیم
await Task.CompletedTask;
return null;
}
public Task DeleteThumbnailAsync(string thumbnailPath)
{
if (File.Exists(thumbnailPath))
{
File.Delete(thumbnailPath);
}
return Task.CompletedTask;
}
public async Task<(int Width, int Height)?> GetImageDimensionsAsync(string imagePath)
{
try
{
if (!File.Exists(imagePath))
{
return null;
}
var imageInfo = await Image.IdentifyAsync(imagePath);
return (imageInfo.Width, imageInfo.Height);
}
catch (Exception)
{
return null;
}
}
/// <summary>
/// دریافت مسیر فیزیکی و URL برای thumbnail بر اساس category
/// </summary>
private (string ThumbnailPath, string ThumbnailUrl) GetThumbnailPathAndUrl(string thumbnailFileName, string category)
{
var categoryFolder = string.IsNullOrWhiteSpace(category) ? "general" : category;
var categoryPath = Path.Combine(Directory.GetCurrentDirectory(), "storage", categoryFolder);
if (!Directory.Exists(categoryPath))
{
Directory.CreateDirectory(categoryPath);
}
var thumbnailSubPath = Path.Combine(categoryPath, "Thumbnails");
if (!Directory.Exists(thumbnailSubPath))
{
Directory.CreateDirectory(thumbnailSubPath);
}
var thumbnailPath = Path.Combine(thumbnailSubPath, thumbnailFileName);
var thumbnailUrl = $"{_baseUrl}/{categoryFolder}/thumbnails/{thumbnailFileName}";
return (thumbnailPath, thumbnailUrl);
}
}

View File

@@ -22,6 +22,8 @@ using GozareshgirProgramManager.Application.Modules.Projects.Queries.GetProjectH
using MediatR;
using Microsoft.AspNetCore.Mvc;
using ServiceHost.BaseControllers;
using GozareshgirProgramManager.Application.Modules.Projects.Commands.ChangeTaskPriority;
using GozareshgirProgramManager.Application.Modules.Projects.Commands.AutoPendingFullTimeTaskSections;
namespace ServiceHost.Areas.Admin.Controllers.ProgramManager;
@@ -122,6 +124,8 @@ public class ProjectController : ProgramManagerBaseController
{
// اجرای Command برای متوقف کردن تسک‌های overtime قبل از نمایش
await _mediator.Send(new AutoStopOverTimeTaskSectionsCommand());
// سپس تسک‌هایی که به 100% زمان تخمینی رسیده‌اند را به حالت PendingForCompletion ببریم
await _mediator.Send(new AutoPendingFullTimeTaskSectionsCommand());
var res = await _mediator.Send(query);
return res;
@@ -165,4 +169,12 @@ public class ProjectController : ProgramManagerBaseController
var res = await _mediator.Send(command);
return res;
}
[HttpPost("change-priority")]
public async Task<ActionResult<OperationResult>> ChangePriority([FromBody] ChangeTaskPriorityCommand command)
{
var res = await _mediator.Send(command);
return res;
}
}

View File

@@ -0,0 +1,144 @@
using GozareshgirProgramManager.Application._Common.Models;
using GozareshgirProgramManager.Application.Modules.TaskChat.Commands.DeleteMessage;
using GozareshgirProgramManager.Application.Modules.TaskChat.Commands.EditMessage;
using GozareshgirProgramManager.Application.Modules.TaskChat.Commands.PinMessage;
using GozareshgirProgramManager.Application.Modules.TaskChat.Commands.SendMessage;
using GozareshgirProgramManager.Application.Modules.TaskChat.Commands.UnpinMessage;
using GozareshgirProgramManager.Application.Modules.TaskChat.DTOs;
using GozareshgirProgramManager.Application.Modules.TaskChat.Queries.GetMessages;
using GozareshgirProgramManager.Application.Modules.TaskChat.Queries.GetPinnedMessages;
using GozareshgirProgramManager.Application.Modules.TaskChat.Queries.SearchMessages;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using ServiceHost.BaseControllers;
namespace ServiceHost.Areas.Admin.Controllers.ProgramManager;
/// <summary>
/// کنترلر مدیریت چت تسک
/// </summary>
public class TaskChatController : ProgramManagerBaseController
{
private readonly IMediator _mediator;
public TaskChatController(IMediator mediator)
{
_mediator = mediator;
}
/// <summary>
/// دریافت لیست پیام‌های یک تسک
/// </summary>
/// <param name="taskId">شناسه تسک</param>
/// <param name="page">صفحه (پیش‌فرض: 1)</param>
/// <param name="pageSize">تعداد در هر صفحه (پیش‌فرض: 50)</param>
[HttpGet("{taskId:guid}/messages")]
public async Task<ActionResult<OperationResult<PaginationResult<MessageDto>>>> GetMessages(
Guid taskId,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 50)
{
var query = new GetMessagesQuery(taskId, page, pageSize);
var result = await _mediator.Send(query);
return result;
}
/// <summary>
/// دریافت پیام‌های پین شده یک تسک
/// </summary>
/// <param name="taskId">شناسه تسک</param>
[HttpGet("{taskId:guid}/messages/pinned")]
public async Task<ActionResult<OperationResult<List<MessageDto>>>> GetPinnedMessages(Guid taskId)
{
var query = new GetPinnedMessagesQuery(taskId);
var result = await _mediator.Send(query);
return result;
}
/// <summary>
/// جستجو در پیام‌های یک تسک
/// </summary>
/// <param name="taskId">شناسه تسک</param>
/// <param name="search">متن جستجو</param>
/// <param name="page">صفحه</param>
/// <param name="pageSize">تعداد در هر صفحه</param>
[HttpGet("{taskId:guid}/messages/search")]
public async Task<ActionResult<OperationResult<List<MessageDto>>>> SearchMessages(
Guid taskId,
[FromQuery] string search,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
var query = new SearchMessagesQuery(taskId, search, page, pageSize);
var result = await _mediator.Send(query);
return result;
}
/// <summary>
/// ارسال پیام جدید (با یا بدون فایل)
/// </summary>
[HttpPost("messages")]
public async Task<ActionResult<OperationResult<MessageDto>>> SendMessage(
[FromForm] SendMessageCommand command)
{
var result = await _mediator.Send(command);
return result;
}
/// <summary>
/// ویرایش پیام (فقط متن)
/// </summary>
/// <param name="messageId">شناسه پیام</param>
/// <param name="request">محتوای جدید</param>
[HttpPut("messages/{messageId:guid}")]
public async Task<ActionResult<OperationResult>> EditMessage(
Guid messageId,
[FromBody] EditMessageRequest request)
{
var command = new EditMessageCommand(messageId, request.NewTextContent);
var result = await _mediator.Send(command);
return result;
}
/// <summary>
/// حذف پیام
/// </summary>
/// <param name="messageId">شناسه پیام</param>
[HttpDelete("messages/{messageId:guid}")]
public async Task<ActionResult<OperationResult>> DeleteMessage(Guid messageId)
{
var command = new DeleteMessageCommand(messageId);
var result = await _mediator.Send(command);
return result;
}
/// <summary>
/// پین کردن پیام
/// </summary>
/// <param name="messageId">شناسه پیام</param>
[HttpPost("messages/{messageId:guid}/pin")]
public async Task<ActionResult<OperationResult>> PinMessage(Guid messageId)
{
var command = new PinMessageCommand(messageId);
var result = await _mediator.Send(command);
return result;
}
/// <summary>
/// برداشتن پین پیام
/// </summary>
/// <param name="messageId">شناسه پیام</param>
[HttpPost("messages/{messageId:guid}/unpin")]
public async Task<ActionResult<OperationResult>> UnpinMessage(Guid messageId)
{
var command = new UnpinMessageCommand(messageId);
var result = await _mediator.Send(command);
return result;
}
}
public class EditMessageRequest
{
public string NewTextContent { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,59 @@
using _0_Framework.Application;
using _0_Framework.Domain.CustomizeCheckoutShared.Enums;
using CompanyManagment.App.Contracts.Loan;
using Microsoft.AspNetCore.Mvc;
using ServiceHost.BaseControllers;
namespace ServiceHost.Areas.Client.Controllers;
public class LoanController: ClientBaseController
{
private readonly ILoanApplication _loanApplication;
private readonly long _workshopId;
public LoanController(ILoanApplication loanApplication, IAuthHelper authHelper)
{
_loanApplication = loanApplication;
_workshopId= authHelper.GetWorkshopId();
}
[HttpGet]
public ActionResult<LoanGroupedViewModel> GetList(LoanSearchViewModel searchModel)
{
searchModel.WorkshopId = _workshopId;
var loans = _loanApplication.GetSearchListAsGrouped(searchModel);
return loans;
}
[HttpGet("{id}")]
public async Task<ActionResult<LoanDetailsViewModel>> GetDetails(long id)
{
var loan = await _loanApplication.GetDetails(id);
return loan;
}
[HttpPost]
public ActionResult<OperationResult> Create([FromBody] CreateLoanViewModel command)
{
var result = _loanApplication.Create(command);
return result;
}
[HttpGet("create/installments")]
public ActionResult<List<LoanInstallmentViewModel>> CalculateLoanInstallment(string amount,
int installmentCount, string loanStartDate, bool getRounded)
{
var installments =
_loanApplication.CalculateLoanInstallment(amount, installmentCount, loanStartDate, getRounded);
return installments;
}
[HttpDelete("{id}")]
public ActionResult<OperationResult> Remove(long id)
{
var result = _loanApplication.Remove(id);
return result;
}
}

View File

@@ -63,10 +63,17 @@ if (!Directory.Exists(logDirectory))
Directory.CreateDirectory(logDirectory);
}
// فقط برای فایل از Serilog استفاده می‌شود
// تنظیمات MinimumLevel از appsettings.json خوانده می‌شود
Log.Logger = new LoggerConfiguration()
.Enrich.FromLogContext()
//NO EF Core log
.MinimumLevel.Override("Microsoft.EntityFrameworkCore", LogEventLevel.Warning)
//NO DbCommand log
.MinimumLevel.Override("Microsoft.EntityFrameworkCore.Database.Command", LogEventLevel.Warning)
//NO Microsoft Public log
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
//.MinimumLevel.Information()
.WriteTo.File(
path: Path.Combine(logDirectory, "gozareshgir_log.txt"),
rollingInterval: RollingInterval.Day,
@@ -373,23 +380,30 @@ builder.Services.AddParbad().ConfigureGateways(gateways =>
});
// فقط Serilog برای File استفاده می‌شه، کنسول از لاگر پیش‌فرض ASP.NET استفاده می‌کنه
builder.Host.UseSerilog((context, services, configuration) =>
if (builder.Environment.IsDevelopment())
{
var logConfig = configuration
.ReadFrom.Configuration(context.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext();
builder.Host.UseSerilog((context, services, configuration) =>
{
var logConfig = configuration
.ReadFrom.Configuration(context.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext();
logConfig.WriteTo.File(
path: Path.Combine(logDirectory, "gozareshgir_log.txt"),
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 30,
shared: true,
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss} [{Level}] {Message}{NewLine}{Exception}"
);
}, writeToProviders: true); // این باعث میشه کنسول پیش‌فرض هم کار کنه
logConfig.WriteTo.File(
path: Path.Combine(logDirectory, "gozareshgir_log.txt"),
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 30,
shared: true,
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss} [{Level}] {Message}{NewLine}{Exception}"
);
}, writeToProviders: true); // این باعث میشه کنسول پیش‌فرض هم کار کنه
}
else
{
builder.Host.UseSerilog();
}
Log.Information("SERILOG STARTED SUCCESSFULLY");
@@ -486,6 +500,24 @@ app.UseHttpsRedirection();
app.UseStaticFiles();
// Static files برای فایل‌های آپلود شده
var uploadsPath = builder.Configuration["FileStorage:LocalPath"] ?? Path.Combine(Directory.GetCurrentDirectory(), "Storage");
if (!Directory.Exists(uploadsPath))
{
Directory.CreateDirectory(uploadsPath);
}
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(uploadsPath),
RequestPath = "/storage",
OnPrepareResponse = ctx =>
{
// Cache برای فایل‌ها (30 روز)
ctx.Context.Response.Headers.Append("Cache-Control", "public,max-age=2592000");
}
});
app.UseCookiePolicy();

View File

@@ -19,7 +19,7 @@
"sqlDebugging": true,
"dotnetRunMessages": "true",
"nativeDebugging": true,
"applicationUrl": "https://localhost:5004;http://localhost:5003;https://192.168.0.117:5006",
"applicationUrl": "https://localhost:5004;http://localhost:5003;",
"jsWebView2Debugging": false,
"hotReloadEnabled": true
},
@@ -44,7 +44,7 @@
"sqlDebugging": true,
"dotnetRunMessages": "true",
"nativeDebugging": true,
"applicationUrl": "https://localhost:5004;http://localhost:5003;https://192.168.0.117:5006;",
"applicationUrl": "https://localhost:5004;http://localhost:5003;",
"jsWebView2Debugging": false,
"hotReloadEnabled": true
}

Some files were not shown because too many files have changed in this diff Show More