Merge branch 'Feature/program-manager/chat'

# Conflicts:
#	.gitignore
#	ServiceHost/appsettings.Development.json
#	ServiceHost/appsettings.json
This commit is contained in:
2026-01-08 13:51:06 +03:30
96 changed files with 3978 additions and 1 deletions

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

@@ -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);
}