Merge branch 'Feature/program-manager/chat' into Main
# Conflicts: # .gitignore
4
.gitignore
vendored
@@ -365,3 +365,7 @@ MigrationBackup/
|
||||
/ServiceHost/appsettings.Development.json
|
||||
/ServiceHost/appsettings.json
|
||||
/ServiceHost/web.config
|
||||
|
||||
# Storage folder - ignore all uploaded files, thumbnails, and temporary files
|
||||
ServiceHost/Storage
|
||||
|
||||
|
||||
@@ -106,7 +106,8 @@ public class SendMessageCommandHandler : IBaseCommandHandler<SendMessageCommand,
|
||||
uploadedFile.SetImageDimensions(dimensions.Value.Width, dimensions.Value.Height);
|
||||
}
|
||||
|
||||
var thumbnail = await _thumbnailService.GenerateImageThumbnailAsync(uploadResult.StoragePath);
|
||||
var thumbnail = await _thumbnailService
|
||||
.GenerateImageThumbnailAsync(uploadResult.StoragePath, category: "TaskChatMessage");
|
||||
if (thumbnail.HasValue)
|
||||
{
|
||||
uploadedFile.SetThumbnail(thumbnail.Value.ThumbnailUrl);
|
||||
|
||||
@@ -43,16 +43,37 @@ public class GetMessagesQueryHandler : IBaseQueryHandler<GetMessagesQuery, Pagin
|
||||
.Take(request.PageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// ✅ گرفتن تمامی کاربران برای نمایش نام کامل فرستنده به جای "کاربر"
|
||||
// این بخش تمام UserId هایی که در پیامها استفاده شده را جمعآوری میکند
|
||||
// و یک Dictionary ایجاد میکند که UserId را به FullName نگاشت میکند
|
||||
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) برای نمایش به صورت نوت
|
||||
// در اینجا تمامی TaskSections مربوط به این تسک را میگیریم
|
||||
// و برای هر کدام تمام AdditionalTimes آن را بارگذاری میکنیم
|
||||
var taskSections = await _context.TaskSections
|
||||
.Where(ts => ts.TaskId == request.TaskId)
|
||||
.Include(ts => ts.AdditionalTimes)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var messageDtos = new List<MessageDto>();
|
||||
|
||||
foreach (var message in messages)
|
||||
{
|
||||
// ✅ نام فرستنده را از Dictionary Users بگیر، در صورت عدم وجود "کاربر ناشناس" نمایش بده
|
||||
var senderName = users.ContainsKey(message.SenderUserId)
|
||||
? users[message.SenderUserId]
|
||||
: "کاربر ناشناس";
|
||||
|
||||
var dto = new MessageDto
|
||||
{
|
||||
Id = message.Id,
|
||||
TaskId = message.TaskId,
|
||||
SenderUserId = message.SenderUserId,
|
||||
SenderName = "کاربر", // TODO: از User service
|
||||
SenderName = senderName, // ✅ از User واقعی استفاده میکنیم
|
||||
MessageType = message.MessageType.ToString(),
|
||||
TextContent = message.TextContent,
|
||||
ReplyToMessageId = message.ReplyToMessageId,
|
||||
@@ -67,11 +88,16 @@ public class GetMessagesQueryHandler : IBaseQueryHandler<GetMessagesQuery, Pagin
|
||||
|
||||
if (message.ReplyToMessage != null)
|
||||
{
|
||||
// ✅ برای پیامهای Reply نیز نام فرستنده را درست نمایش بده
|
||||
var replySenderName = users.ContainsKey(message.ReplyToMessage.SenderUserId)
|
||||
? users[message.ReplyToMessage.SenderUserId]
|
||||
: "کاربر ناشناس";
|
||||
|
||||
dto.ReplyToMessage = new MessageDto
|
||||
{
|
||||
Id = message.ReplyToMessage.Id,
|
||||
SenderUserId = message.ReplyToMessage.SenderUserId,
|
||||
SenderName = "کاربر",
|
||||
SenderName = replySenderName,
|
||||
TextContent = message.ReplyToMessage.TextContent,
|
||||
CreationDate = message.ReplyToMessage.CreationDate
|
||||
};
|
||||
@@ -98,8 +124,58 @@ public class GetMessagesQueryHandler : IBaseQueryHandler<GetMessagesQuery, Pagin
|
||||
}
|
||||
|
||||
messageDtos.Add(dto);
|
||||
|
||||
// ✅ اینجا بخش جدید است: نوتهای زمان اضافی را بین پیامها اضافه کن
|
||||
// این بخش تمام AdditionalTimes را که بعد از این پیام اضافه شدهاند را پیدا میکند
|
||||
var additionalTimesAfterMessage = taskSections
|
||||
.SelectMany(ts => ts.AdditionalTimes)
|
||||
.Where(at => at.AddedAt > message.CreationDate) // ✅ تغییر به AddedAt (زمان واقعی اضافه شدن)
|
||||
.OrderBy(at => at.AddedAt)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (additionalTimesAfterMessage != null)
|
||||
{
|
||||
// ✅ تمام AdditionalTimes بین این پیام و پیام قبلی را بگیر
|
||||
var additionalTimesByDate = taskSections
|
||||
.SelectMany(ts => ts.AdditionalTimes)
|
||||
.Where(at => at.AddedAt <= message.CreationDate &&
|
||||
(messageDtos.Count == 1 || at.AddedAt > messageDtos[messageDtos.Count - 2].CreationDate))
|
||||
.OrderBy(at => at.AddedAt)
|
||||
.ToList();
|
||||
|
||||
foreach (var additionalTime in additionalTimesByDate)
|
||||
{
|
||||
// ✅ نام کاربری که این زمان اضافی را اضافه کرد
|
||||
var addedByUserName = additionalTime.AddedByUserId.HasValue && users.TryGetValue(additionalTime.AddedByUserId.Value, out var user)
|
||||
? user
|
||||
: "سیستم";
|
||||
|
||||
// ✅ محتوای نوت را با اطلاعات کامل ایجاد کن
|
||||
// نمایش میدهد: مقدار زمان + علت + نام کسی که اضافه کرد
|
||||
var noteContent = $"⏱️ زمان اضافی: {additionalTime.Hours.TotalHours:F2} ساعت - {(string.IsNullOrWhiteSpace(additionalTime.Reason) ? "بدون علت" : additionalTime.Reason)} - توسط {addedByUserName}";
|
||||
|
||||
// ✅ نوت را به عنوان MessageDto خاصی ایجاد کن
|
||||
var noteDto = new MessageDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TaskId = request.TaskId,
|
||||
SenderUserId = 0, // ✅ سیستم برای نشان دادن اینکه یک پیام خودکار است
|
||||
SenderName = "سیستم",
|
||||
MessageType = "Note", // ✅ نوع پیام: Note (یادداشت سیستم)
|
||||
TextContent = noteContent,
|
||||
CreationDate = additionalTime.AddedAt, // ✅ تاریخ اضافه شدن زمان اضافی
|
||||
IsMine = false
|
||||
};
|
||||
|
||||
messageDtos.Add(noteDto);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ مرتب کردن نهایی تمام پیامها (معمولی + نوتها) بر اساس زمان ایجاد
|
||||
// اینطور که نوتهای زمان اضافی در جای درست خود قرار میگیرند
|
||||
messageDtos = messageDtos.OrderBy(m => m.CreationDate).ToList();
|
||||
|
||||
var response = new PaginationResult<MessageDto>()
|
||||
{
|
||||
List = messageDtos,
|
||||
|
||||
@@ -29,16 +29,25 @@ public class GetPinnedMessagesQueryHandler : IBaseQueryHandler<GetPinnedMessages
|
||||
.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 = senderName,
|
||||
MessageType = message.MessageType.ToString(),
|
||||
TextContent = message.TextContent,
|
||||
IsPinned = message.IsPinned,
|
||||
|
||||
@@ -40,15 +40,24 @@ public class SearchMessagesQueryHandler : IBaseQueryHandler<SearchMessagesQuery,
|
||||
.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 = senderName,
|
||||
MessageType = message.MessageType.ToString(),
|
||||
TextContent = message.TextContent,
|
||||
CreationDate = message.CreationDate,
|
||||
|
||||
@@ -9,15 +9,17 @@ public interface IThumbnailGeneratorService
|
||||
/// تولید thumbnail برای تصویر
|
||||
/// </summary>
|
||||
Task<(string ThumbnailPath, string ThumbnailUrl)?> GenerateImageThumbnailAsync(
|
||||
string imagePath,
|
||||
int width = 200,
|
||||
string imagePath,
|
||||
string category,
|
||||
int width = 200,
|
||||
int height = 200);
|
||||
|
||||
/// <summary>
|
||||
/// تولید thumbnail برای ویدیو
|
||||
/// </summary>
|
||||
Task<(string ThumbnailPath, string ThumbnailUrl)?> GenerateVideoThumbnailAsync(
|
||||
string videoPath);
|
||||
string videoPath,
|
||||
string category);
|
||||
|
||||
/// <summary>
|
||||
/// حذف thumbnail
|
||||
|
||||
@@ -62,17 +62,25 @@ public class TaskChatMessage : EntityBase<Guid>
|
||||
|
||||
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)
|
||||
@@ -98,7 +106,7 @@ public class TaskChatMessage : EntityBase<Guid>
|
||||
throw new BadRequestException("فقط فرستنده میتواند پیام را ویرایش کند");
|
||||
}
|
||||
|
||||
if (MessageType != MessageType.Text)
|
||||
if (MessageType != MessageType.Text || (MessageType != MessageType.Text && !string.IsNullOrWhiteSpace(TextContent)))
|
||||
{
|
||||
throw new BadRequestException("فقط پیامهای متنی قابل ویرایش هستند");
|
||||
}
|
||||
|
||||
@@ -10,5 +10,6 @@ public enum MessageType
|
||||
Image = 3, // تصویر
|
||||
Voice = 4, // پیام صوتی
|
||||
Video = 5, // ویدیو
|
||||
Note = 6, // ✅ یادداشت سیستم (برای زمان اضافی و اطلاعات خودکار)
|
||||
}
|
||||
|
||||
|
||||
@@ -24,11 +24,11 @@ public class LocalFileStorageService : IFileStorageService
|
||||
var request = httpContextAccessor.HttpContext?.Request;
|
||||
if (request != null)
|
||||
{
|
||||
_baseUrl = $"{request.Scheme}://{request.Host}/uploads";
|
||||
_baseUrl = $"{request.Scheme}://{request.Host}/storage";
|
||||
}
|
||||
else
|
||||
{
|
||||
_baseUrl = configuration["FileStorage:BaseUrl"] ?? "http://localhost:5000/uploads";
|
||||
_baseUrl = configuration["FileStorage:BaseUrl"] ?? "http://localhost:5000/storage";
|
||||
}
|
||||
|
||||
// ایجاد پوشه اگر وجود نداشت
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using GozareshgirProgramManager.Application.Services.FileManagement;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Processing;
|
||||
@@ -14,12 +15,21 @@ public class ThumbnailGeneratorService : IThumbnailGeneratorService
|
||||
private readonly string _thumbnailBasePath;
|
||||
private readonly string _baseUrl;
|
||||
|
||||
public ThumbnailGeneratorService(IConfiguration configuration)
|
||||
public ThumbnailGeneratorService(IConfiguration configuration,
|
||||
IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
_thumbnailBasePath = configuration["FileStorage:ThumbnailPath"]
|
||||
?? Path.Combine(Directory.GetCurrentDirectory(), "Uploads", "Thumbnails");
|
||||
?? Path.Combine(Directory.GetCurrentDirectory(), "storage", "Thumbnails");
|
||||
var request = httpContextAccessor.HttpContext?.Request;
|
||||
|
||||
_baseUrl = configuration["FileStorage:BaseUrl"] ?? "http://localhost:5000/uploads";
|
||||
if (request != null)
|
||||
{
|
||||
_baseUrl = $"{request.Scheme}://{request.Host}/storage";
|
||||
}
|
||||
else
|
||||
{
|
||||
_baseUrl = configuration["FileStorage:BaseUrl"] ?? "http://localhost:5000/storage";
|
||||
}
|
||||
|
||||
if (!Directory.Exists(_thumbnailBasePath))
|
||||
{
|
||||
@@ -28,7 +38,8 @@ public class ThumbnailGeneratorService : IThumbnailGeneratorService
|
||||
}
|
||||
|
||||
public async Task<(string ThumbnailPath, string ThumbnailUrl)?> GenerateImageThumbnailAsync(
|
||||
string imagePath,
|
||||
string imagePath,
|
||||
string category,
|
||||
int width = 200,
|
||||
int height = 200)
|
||||
{
|
||||
@@ -54,14 +65,12 @@ public class ThumbnailGeneratorService : IThumbnailGeneratorService
|
||||
var extension = Path.GetExtension(imagePath);
|
||||
var thumbnailFileName = $"{fileName}_thumb{extension}";
|
||||
|
||||
// مسیر ذخیره thumbnail
|
||||
var thumbnailPath = Path.Combine(_thumbnailBasePath, thumbnailFileName);
|
||||
// دریافت مسیر و URL توسط متد private
|
||||
var (thumbnailPath, thumbnailUrl) = GetThumbnailPathAndUrl(thumbnailFileName, category);
|
||||
|
||||
// ذخیره thumbnail با کیفیت 80
|
||||
await image.SaveAsync(thumbnailPath, new JpegEncoder { Quality = 80 });
|
||||
|
||||
// URL thumbnail
|
||||
var thumbnailUrl = $"{_baseUrl}/thumbnails/{thumbnailFileName}";
|
||||
|
||||
return (thumbnailPath, thumbnailUrl);
|
||||
}
|
||||
@@ -72,7 +81,7 @@ public class ThumbnailGeneratorService : IThumbnailGeneratorService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(string ThumbnailPath, string ThumbnailUrl)?> GenerateVideoThumbnailAsync(string videoPath)
|
||||
public async Task<(string ThumbnailPath, string ThumbnailUrl)?> GenerateVideoThumbnailAsync(string videoPath, string category = "general")
|
||||
{
|
||||
// TODO: برای Video thumbnail باید از FFmpeg استفاده کنیم
|
||||
// فعلاً یک placeholder image برمیگردانیم
|
||||
@@ -107,5 +116,29 @@ public class ThumbnailGeneratorService : IThumbnailGeneratorService
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -487,7 +487,7 @@ app.UseHttpsRedirection();
|
||||
app.UseStaticFiles();
|
||||
|
||||
// Static files برای فایلهای آپلود شده
|
||||
var uploadsPath = builder.Configuration["FileStorage:LocalPath"] ?? Path.Combine(Directory.GetCurrentDirectory(), "Uploads");
|
||||
var uploadsPath = builder.Configuration["FileStorage:LocalPath"] ?? Path.Combine(Directory.GetCurrentDirectory(), "Storage");
|
||||
if (!Directory.Exists(uploadsPath))
|
||||
{
|
||||
Directory.CreateDirectory(uploadsPath);
|
||||
@@ -496,7 +496,7 @@ if (!Directory.Exists(uploadsPath))
|
||||
app.UseStaticFiles(new StaticFileOptions
|
||||
{
|
||||
FileProvider = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(uploadsPath),
|
||||
RequestPath = "/uploads",
|
||||
RequestPath = "/storage",
|
||||
OnPrepareResponse = ctx =>
|
||||
{
|
||||
// Cache برای فایلها (30 روز)
|
||||
|
||||
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 312 KiB |
|
Before Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 328 KiB |
|
Before Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 312 KiB |
|
Before Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 312 KiB |
|
Before Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 505 KiB |
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 338 KiB |
|
Before Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 485 KiB |
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 387 KiB |
|
Before Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 312 KiB |
|
Before Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 485 KiB |
|
Before Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 485 KiB |
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 338 KiB |
|
Before Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 387 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 472 KiB |
|
Before Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 472 KiB |
|
Before Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 420 KiB |
|
Before Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 472 KiB |
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 387 KiB |
|
Before Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 512 KiB |
|
Before Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 387 KiB |
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 387 KiB |
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 387 KiB |
|
Before Width: | Height: | Size: 395 KiB |
|
Before Width: | Height: | Size: 394 KiB |
|
Before Width: | Height: | Size: 357 KiB |
|
Before Width: | Height: | Size: 357 KiB |