diff --git a/backend/src/PnvPanel.Application/Support/TicketMapping.cs b/backend/src/PnvPanel.Application/Support/TicketMapping.cs index a8eddb8..21bb0f5 100644 --- a/backend/src/PnvPanel.Application/Support/TicketMapping.cs +++ b/backend/src/PnvPanel.Application/Support/TicketMapping.cs @@ -99,6 +99,18 @@ internal static class TicketMapping .Select(g => new { TicketId = g.Key, Last = g.Max(c => c.CreatedAt) }) .ToDictionaryAsync(x => x.TicketId, x => x.Last, cancellationToken); + // Первое сообщение (не последний комментарий) — это исходный текст обращения, а не переписка. + var firstMessages = await dbContext + .TicketComments.AsNoTracking() + .Where(c => ticketIds.Contains(c.TicketId)) + .GroupBy(c => c.TicketId) + .Select(g => new + { + TicketId = g.Key, + Body = g.OrderBy(c => c.CreatedAt).Select(c => c.Body).First(), + }) + .ToDictionaryAsync(x => x.TicketId, x => x.Body, cancellationToken); + var userIds = tickets.Select(t => t.UserId).Distinct().ToList(); var userNames = await identityService.GetUserNamesAsync(userIds, cancellationToken); @@ -110,8 +122,17 @@ internal static class TicketMapping t.Type, t.Status, t.CreatedAt, - lastActivity.GetValueOrDefault(t.Id, t.CreatedAt) + lastActivity.GetValueOrDefault(t.Id, t.CreatedAt), + TruncatePreview(firstMessages.GetValueOrDefault(t.Id)) )) .ToList(); } + + private static string? TruncatePreview(string? message) + { + if (string.IsNullOrEmpty(message)) + return message; + + return message.Length > 200 ? message[..200] + "…" : message; + } } diff --git a/backend/src/PnvPanel.Application/Support/TicketSummaryDto.cs b/backend/src/PnvPanel.Application/Support/TicketSummaryDto.cs index f189e0e..6d0a601 100644 --- a/backend/src/PnvPanel.Application/Support/TicketSummaryDto.cs +++ b/backend/src/PnvPanel.Application/Support/TicketSummaryDto.cs @@ -3,7 +3,8 @@ using PnvPanel.Domain.Support; namespace PnvPanel.Application.Support; /// Строка списка тикетов — свой (ListMyTicketsQuery) и админский (ListAllTicketsQuery) списки -/// используют один и тот же DTO (владелец видит только свои UserId/UserName — не секрет для себя). +/// используют один и тот же DTO (владелец видит только свои UserId/UserName — не секрет для себя). +/// MessagePreview — начало первого сообщения тикета (обрезано), для превью в списке. public sealed record TicketSummaryDto( Guid Id, Guid UserId, @@ -11,5 +12,6 @@ public sealed record TicketSummaryDto( TicketType Type, TicketStatus Status, DateTimeOffset CreatedAt, - DateTimeOffset LastActivityAt + DateTimeOffset LastActivityAt, + string? MessagePreview ); diff --git a/backend/tests/PnvPanel.Application.Tests/Support/ListMyTicketsQueryHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Support/ListMyTicketsQueryHandlerTests.cs new file mode 100644 index 0000000..548c5e8 --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Support/ListMyTicketsQueryHandlerTests.cs @@ -0,0 +1,84 @@ +using NSubstitute; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Support; +using PnvPanel.Application.Support.ListMyTickets; +using PnvPanel.Application.Tests.TestSupport; +using PnvPanel.Domain.Support; +using Xunit; + +namespace PnvPanel.Application.Tests.Support; + +public class ListMyTicketsQueryHandlerTests +{ + private readonly IIdentityService _identityService = Substitute.For(); + + [Fact] + public async Task Handle_ReturnsFirstMessageAsPreview_NotLastComment() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var userId = Guid.NewGuid(); + var ticket = SupportTicket.CreateBugReport(userId); + dbContext.SupportTickets.Add(ticket); + + var firstComment = TicketComment.Create(ticket.Id, userId, "исходное сообщение о баге"); + dbContext.TicketComments.Add(firstComment); + await dbContext.SaveChangesAsync(CancellationToken.None); + + // Гарантируем, что второй комментарий получит более поздний CreatedAt (UtcNow может + // совпасть при быстром последовательном создании без явного clock-абстрагирования). + await Task.Delay(5, CancellationToken.None); + var replyComment = TicketComment.Create( + ticket.Id, + userId, + "более поздний ответ администратора" + ); + dbContext.TicketComments.Add(replyComment); + await dbContext.SaveChangesAsync(CancellationToken.None); + + _identityService + .GetUserNamesAsync(Arg.Any>(), Arg.Any()) + .Returns(new Dictionary { [userId] = "alice" }); + + var currentUser = FakeCurrentUser.Authenticated(userId, "alice"); + var handler = new ListMyTicketsQueryHandler(dbContext, _identityService, currentUser); + + var result = await handler.Handle( + new ListMyTicketsQuery(TypeFilter: null, StatusFilter: null, Page: 1, PageSize: 20), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + var summary = Assert.Single(result.Value.Items); + Assert.Equal("исходное сообщение о баге", summary.MessagePreview); + } + + [Fact] + public async Task Handle_TruncatesLongMessagePreview() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var userId = Guid.NewGuid(); + var ticket = SupportTicket.CreateBugReport(userId); + dbContext.SupportTickets.Add(ticket); + + var longBody = new string('x', 250); + dbContext.TicketComments.Add(TicketComment.Create(ticket.Id, userId, longBody)); + await dbContext.SaveChangesAsync(CancellationToken.None); + + _identityService + .GetUserNamesAsync(Arg.Any>(), Arg.Any()) + .Returns(new Dictionary { [userId] = "alice" }); + + var currentUser = FakeCurrentUser.Authenticated(userId, "alice"); + var handler = new ListMyTicketsQueryHandler(dbContext, _identityService, currentUser); + + var result = await handler.Handle( + new ListMyTicketsQuery(TypeFilter: null, StatusFilter: null, Page: 1, PageSize: 20), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + var summary = Assert.Single(result.Value.Items); + Assert.Equal(201, summary.MessagePreview!.Length); + Assert.EndsWith("…", summary.MessagePreview); + } +} diff --git a/frontend/src/features/support/SupportTicketList.tsx b/frontend/src/features/support/SupportTicketList.tsx index 0866670..7d16764 100644 --- a/frontend/src/features/support/SupportTicketList.tsx +++ b/frontend/src/features/support/SupportTicketList.tsx @@ -52,7 +52,8 @@ export function SupportTicketList() { {t(`support.type.${ticket.type}`)} - + + {ticket.messagePreview &&

{ticket.messagePreview}

}

{t('support.lastActivity', { date: new Date(ticket.lastActivityAt).toLocaleString() })}

diff --git a/frontend/src/routes/admin/support.tsx b/frontend/src/routes/admin/support.tsx index 5af5d27..89c9905 100644 --- a/frontend/src/routes/admin/support.tsx +++ b/frontend/src/routes/admin/support.tsx @@ -55,7 +55,8 @@ function AdminSupportPage() { - + + {ticket.messagePreview &&

{ticket.messagePreview}

}

{t('support.lastActivity', { date: new Date(ticket.lastActivityAt).toLocaleString() })}

diff --git a/frontend/src/shared/api/types.ts b/frontend/src/shared/api/types.ts index 673b1fb..82e96ad 100644 --- a/frontend/src/shared/api/types.ts +++ b/frontend/src/shared/api/types.ts @@ -267,6 +267,7 @@ export type TicketSummaryDto = { status: TicketStatus createdAt: string lastActivityAt: string + messagePreview: string | null } export type TicketDetailDto = {