diff --git a/src/Application/Features/Message/Report/ReportMessageCommand.cs b/src/Application/Features/Message/Report/ReportMessageCommand.cs
new file mode 100644
index 0000000..433cdfe
--- /dev/null
+++ b/src/Application/Features/Message/Report/ReportMessageCommand.cs
@@ -0,0 +1,55 @@
+using System.Text.Json.Serialization;
+using Application.Common;
+using Domain.Common;
+using Domain.Enums;
+
+namespace Application.Features.Message.Report;
+
+///
+/// Command để report tin nhắn
+/// Pattern: Command Pattern (CQRS)
+///
+public sealed record ReportMessageCommand : ICommand>
+{
+ ///
+ /// ID của tin nhắn cần report
+ ///
+ [JsonIgnore]
+ public Guid MessageId { get; init; }
+
+ ///
+ /// Danh mục report
+ ///
+ public required ReportCategory Category { get; init; }
+
+ ///
+ /// Lý do chi tiết (tùy chọn)
+ ///
+ public string? Reason { get; init; }
+}
+
+///
+/// Response sau khi report tin nhắn
+///
+public sealed record ReportMessageResponse
+{
+ ///
+ /// ID của report
+ ///
+ public required Guid ReportId { get; init; }
+
+ ///
+ /// ID của tin nhắn được report
+ ///
+ public required Guid MessageId { get; init; }
+
+ ///
+ /// Danh mục report
+ ///
+ public required ReportCategory Category { get; init; }
+
+ ///
+ /// Thời gian tạo report
+ ///
+ public required DateTimeOffset CreatedAt { get; init; }
+}
diff --git a/src/Application/Features/Message/Report/ReportMessageCommandHandler.cs b/src/Application/Features/Message/Report/ReportMessageCommandHandler.cs
new file mode 100644
index 0000000..cdae52b
--- /dev/null
+++ b/src/Application/Features/Message/Report/ReportMessageCommandHandler.cs
@@ -0,0 +1,80 @@
+using Application.Common;
+using Application.Interfaces.Repositories;
+using Application.Interfaces.Services.Auth;
+using Domain.Common;
+using Domain.Entities;
+using Domain.Enums;
+
+namespace Application.Features.Message.Report;
+
+///
+/// Handler xử lý ReportMessageCommand
+/// Pattern: Command Handler Pattern + Template Method Pattern
+///
+public sealed class ReportMessageCommandHandler(
+ ICurrentUserService currentUserService,
+ IMessageRepository messageRepository,
+ IReportRepository reportRepository) : ICommandHandler>
+{
+ private readonly ICurrentUserService _currentUserService = currentUserService
+ ?? throw new ArgumentNullException(nameof(currentUserService));
+ private readonly IMessageRepository _messageRepository = messageRepository
+ ?? throw new ArgumentNullException(nameof(messageRepository));
+ private readonly IReportRepository _reportRepository = reportRepository
+ ?? throw new ArgumentNullException(nameof(reportRepository));
+
+ public async Task> Handle(ReportMessageCommand request, CancellationToken cancellationToken)
+ {
+ // Step 1: Validate user authentication
+ var userId = _currentUserService.UserId;
+ if (userId is null)
+ {
+ return Result.Failure(
+ Error.Unauthorized("User.Unauthenticated", "User is not authenticated"));
+ }
+
+ // Step 2: Get message
+ var message = await _messageRepository.GetByIdAsync(request.MessageId, cancellationToken);
+ if (message is null || message.IsDeleted)
+ {
+ return Result.Failure(
+ Error.NotFound("Message.NotFound", "Message not found"));
+ }
+
+ // Step 3: Validate that user cannot report their own message
+ if (message.SenderId != userId.Value)
+ {
+ return Result.Failure(
+ Error.Validation("Report.NotOwnMessage", "You cannot report not your own message"));
+ }
+
+ // Step 4: Check if user already reported this message
+ var existingReports = await _reportRepository.GetByMessageIdAsync(request.MessageId, cancellationToken);
+ if (existingReports.Any(r => r.ReporterId == userId.Value))
+ {
+ return Result.Failure(
+ Error.Validation("Report.AlreadyReported", "You have already reported this message"));
+ }
+
+ // Step 5: Create report
+ var report = new Domain.Entities.Report
+ {
+ MessageId = request.MessageId,
+ ReporterId = userId.Value,
+ Category = request.Category,
+ Reason = request.Reason,
+ Status = "pending"
+ };
+
+ await _reportRepository.AddAsync(report, cancellationToken);
+
+ // Step 6: Return response
+ return Result.Success(new ReportMessageResponse
+ {
+ ReportId = report.Id,
+ MessageId = report.MessageId,
+ Category = report.Category,
+ CreatedAt = report.CreatedAt
+ });
+ }
+}
diff --git a/src/Application/Features/Message/Report/ReportMessageCommandValidator.cs b/src/Application/Features/Message/Report/ReportMessageCommandValidator.cs
new file mode 100644
index 0000000..7bfef34
--- /dev/null
+++ b/src/Application/Features/Message/Report/ReportMessageCommandValidator.cs
@@ -0,0 +1,22 @@
+using FluentValidation;
+using Domain.Enums;
+
+namespace Application.Features.Message.Report;
+
+///
+/// Validator cho ReportMessageCommand
+///
+public sealed class ReportMessageCommandValidator : AbstractValidator
+{
+ public ReportMessageCommandValidator()
+ {
+ RuleFor(x => x.Category)
+ .IsInEnum()
+ .WithMessage("Danh mục report không hợp lệ");
+
+ RuleFor(x => x.Reason)
+ .MaximumLength(1000)
+ .WithMessage("Lý do chi tiết không được vượt quá 1000 ký tự")
+ .When(x => !string.IsNullOrWhiteSpace(x.Reason));
+ }
+}
diff --git a/src/Application/Interfaces/Repositories/IReportRepository.cs b/src/Application/Interfaces/Repositories/IReportRepository.cs
new file mode 100644
index 0000000..c59e7f9
--- /dev/null
+++ b/src/Application/Interfaces/Repositories/IReportRepository.cs
@@ -0,0 +1,22 @@
+using Application.Common.Models;
+using Domain.Entities;
+
+namespace Application.Interfaces.Repositories;
+
+///
+/// Interface for Report repository to handle data operations.
+///
+public interface IReportRepository
+{
+ Task> GetListAsync(
+ PaginationRequest paginationRequest,
+ CancellationToken cancellationToken = default);
+ Task AddAsync(Report report, CancellationToken cancellationToken = default);
+ Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
+ Task> GetByMessageIdAsync(
+ Guid messageId,
+ CancellationToken cancellationToken = default);
+ Task> GetByReporterIdAsync(
+ Guid reporterId,
+ CancellationToken cancellationToken = default);
+}
diff --git a/src/Domain/Entities/Message.cs b/src/Domain/Entities/Message.cs
index 17c4bb0..7479e10 100644
--- a/src/Domain/Entities/Message.cs
+++ b/src/Domain/Entities/Message.cs
@@ -122,6 +122,8 @@ public string GetPreview(int maxLength = 100)
// Navigation properties
public ThinkingActivity? ThinkingActivity { get; set; }
+ public ICollection Reports { get; set; } = new List();
+
///
/// Start a thinking activity for this AI message
///
diff --git a/src/Domain/Entities/Report.cs b/src/Domain/Entities/Report.cs
new file mode 100644
index 0000000..e523674
--- /dev/null
+++ b/src/Domain/Entities/Report.cs
@@ -0,0 +1,33 @@
+using Domain.Common;
+using Domain.Enums;
+
+namespace Domain.Entities;
+
+///
+/// Report entity for reporting inappropriate messages
+///
+public sealed class Report : BaseEntity
+{
+ public required Guid MessageId { get; set; }
+
+ public required Guid ReporterId { get; set; }
+
+ ///
+ /// Category of the report
+ ///
+ public required ReportCategory Category { get; set; }
+
+ ///
+ /// Detailed reason for reporting
+ ///
+ public string? Reason { get; set; }
+
+ ///
+ /// Status of the report (pending, reviewed, resolved, etc.)
+ ///
+ public string Status { get; set; } = "pending";
+
+ public Message Message { get; set; } = null!;
+
+ public User Reporter { get; set; } = null!;
+}
diff --git a/src/Domain/Enums/ReportCategory.cs b/src/Domain/Enums/ReportCategory.cs
new file mode 100644
index 0000000..e41c3db
--- /dev/null
+++ b/src/Domain/Enums/ReportCategory.cs
@@ -0,0 +1,37 @@
+using System.ComponentModel;
+
+namespace Domain.Enums;
+
+///
+/// Các danh mục báo cáo tin nhắn để cải thiện chất lượng trợ lý pháp lý
+///
+public enum ReportCategory
+{
+ ///
+ /// Nội dung không phù hợp với chủ đề pháp lý hoặc vi phạm tiêu chuẩn
+ /// Ví dụ: Nội dung không liên quan, quảng cáo, hoặc nội dung không phù hợp với đối tượng người dùng
+ ///
+ [Description("Nội dung không phù hợp")]
+ InappropriateContent,
+
+ ///
+ /// Thông tin pháp lý sai lệch, không chính xác hoặc gây hiểu lầm
+ /// Ví dụ: Tư vấn pháp lý không đúng với quy định hiện hành
+ ///
+ [Description("Thông tin sai lệch")]
+ IncorrectInformation,
+
+ ///
+ /// Lỗi kỹ thuật, vấn đề về hiển thị hoặc chức năng của ứng dụng
+ /// Ví dụ: Tin nhắn không hiển thị đúng, lỗi định dạng, hoặc vấn đề về hiệu suất
+ ///
+ [Description("Vấn đề kỹ thuật")]
+ TechnicalIssue,
+
+ ///
+ /// Các vấn đề khác không thuộc các danh mục trên
+ /// Vui lòng mô tả chi tiết trong phần lý do
+ ///
+ [Description("Khác")]
+ Other
+}
diff --git a/src/Infrastructure/Data/Configurations/ReportConfiguration.cs b/src/Infrastructure/Data/Configurations/ReportConfiguration.cs
new file mode 100644
index 0000000..8c41134
--- /dev/null
+++ b/src/Infrastructure/Data/Configurations/ReportConfiguration.cs
@@ -0,0 +1,66 @@
+using Domain.Entities;
+using Domain.Enums;
+using Infrastructure.Data.Contexts;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace Infrastructure.Data.Configurations;
+
+///
+/// Entity configuration for Report
+///
+public sealed class ReportConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("Reports", Schemas.Default);
+
+ // Primary Key
+ builder.HasKey(r => r.Id);
+
+ // Properties
+ builder.Property(r => r.MessageId)
+ .IsRequired();
+
+ builder.Property(r => r.ReporterId)
+ .IsRequired();
+
+ builder.Property(r => r.Category)
+ .IsRequired()
+ .HasConversion();
+
+ builder.Property(r => r.Reason)
+ .HasMaxLength(1000);
+
+ builder.Property(r => r.Status)
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasDefaultValue("pending");
+
+ builder.Property(r => r.CreatedAt)
+ .IsRequired();
+
+ builder.Property(r => r.UpdatedAt);
+
+ // Relationships
+ builder.HasOne(r => r.Message)
+ .WithMany(m => m.Reports)
+ .HasForeignKey(r => r.MessageId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ builder.HasOne(r => r.Reporter)
+ .WithMany()
+ .HasForeignKey(r => r.ReporterId)
+ .OnDelete(DeleteBehavior.Restrict);
+
+ // Indexes
+ builder.HasIndex(r => r.MessageId);
+ builder.HasIndex(r => r.ReporterId);
+ builder.HasIndex(r => r.Category);
+ builder.HasIndex(r => r.Status);
+ builder.HasIndex(r => r.CreatedAt);
+
+ // Query Filter - Only show reports for non-deleted messages from non-deleted conversations and non-deleted users
+ builder.HasQueryFilter(r => !r.Message.IsDeleted && !r.Message.Conversation.IsDeleted && !r.Message.Sender.IsDeleted && !r.Reporter.IsDeleted);
+ }
+}
diff --git a/src/Infrastructure/Data/Contexts/DataContext.cs b/src/Infrastructure/Data/Contexts/DataContext.cs
index 90e9996..5af9340 100644
--- a/src/Infrastructure/Data/Contexts/DataContext.cs
+++ b/src/Infrastructure/Data/Contexts/DataContext.cs
@@ -33,6 +33,7 @@ public DataContext(DbContextOptions options) : base(options)
public DbSet RefreshTokens => Set();
public DbSet Activities => Set();
public DbSet Thoughts => Set();
+ public DbSet Reports => Set();
protected override void OnModelCreating(ModelBuilder builder)
{
diff --git a/src/Infrastructure/Data/Migrations/20251205100156_AddReportTable.Designer.cs b/src/Infrastructure/Data/Migrations/20251205100156_AddReportTable.Designer.cs
new file mode 100644
index 0000000..23d0376
--- /dev/null
+++ b/src/Infrastructure/Data/Migrations/20251205100156_AddReportTable.Designer.cs
@@ -0,0 +1,873 @@
+//
+using System;
+using Infrastructure.Data.Contexts;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace Infrastructure.Data.Migrations
+{
+ [DbContext(typeof(DataContext))]
+ [Migration("20251205100156_AddReportTable")]
+ partial class AddReportTable
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("public")
+ .HasAnnotation("ProductVersion", "8.0.21")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Domain.Entities.Conversation", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("IsDeleted")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false);
+
+ b.Property("IsPrivate")
+ .HasColumnType("boolean");
+
+ b.Property("IsStarred")
+ .HasColumnType("boolean");
+
+ b.Property("OwnerId")
+ .HasColumnType("uuid");
+
+ b.Property("Status")
+ .HasColumnType("integer");
+
+ b.Property("Tags")
+ .IsRequired()
+ .HasColumnType("text[]");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CreatedAt");
+
+ b.HasIndex("IsDeleted");
+
+ b.HasIndex("OwnerId");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("Conversations", "public");
+ });
+
+ modelBuilder.Entity("Domain.Entities.Message", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Content")
+ .IsRequired()
+ .HasMaxLength(10000)
+ .HasColumnType("character varying(10000)");
+
+ b.Property("ConversationId")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EditedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("IsDeleted")
+ .HasColumnType("boolean");
+
+ b.Property("IsEdited")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false);
+
+ b.Property("Metadata")
+ .HasMaxLength(5000)
+ .HasColumnType("character varying(5000)");
+
+ b.Property("Role")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasDefaultValue("user");
+
+ b.Property("SenderId")
+ .HasColumnType("uuid");
+
+ b.Property("Type")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("text")
+ .HasDefaultValue("Text");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ConversationId");
+
+ b.HasIndex("CreatedAt");
+
+ b.HasIndex("Role");
+
+ b.HasIndex("SenderId");
+
+ b.ToTable("Messages", "public");
+ });
+
+ modelBuilder.Entity("Domain.Entities.RefreshToken", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("IsDeleted")
+ .HasColumnType("boolean");
+
+ b.Property("IsPersistent")
+ .HasColumnType("boolean");
+
+ b.Property("ReplacedByToken")
+ .HasColumnType("text");
+
+ b.Property("RevokedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("RevokedReason")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.Property("Token")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ExpiresAt");
+
+ b.HasIndex("Token")
+ .IsUnique();
+
+ b.HasIndex("UserId");
+
+ b.HasIndex("UserId", "RevokedAt");
+
+ b.ToTable("RefreshTokens", "public");
+ });
+
+ modelBuilder.Entity("Domain.Entities.Report", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Category")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("IsDeleted")
+ .HasColumnType("boolean");
+
+ b.Property("MessageId")
+ .HasColumnType("uuid");
+
+ b.Property("Reason")
+ .HasMaxLength(1000)
+ .HasColumnType("character varying(1000)");
+
+ b.Property("ReporterId")
+ .HasColumnType("uuid");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasDefaultValue("pending");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Category");
+
+ b.HasIndex("CreatedAt");
+
+ b.HasIndex("MessageId");
+
+ b.HasIndex("ReporterId");
+
+ b.HasIndex("Status");
+
+ b.ToTable("Reports", "public");
+ });
+
+ modelBuilder.Entity("Domain.Entities.ThinkingActivity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CompletedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Duration")
+ .HasColumnType("interval");
+
+ b.Property("ErrorReason")
+ .HasMaxLength(2000)
+ .HasColumnType("character varying(2000)");
+
+ b.Property("IsDeleted")
+ .HasColumnType("boolean");
+
+ b.Property("MessageId")
+ .HasColumnType("uuid");
+
+ b.Property("StartedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CreatedAt");
+
+ b.HasIndex("MessageId")
+ .IsUnique();
+
+ b.HasIndex("Status");
+
+ b.HasIndex("MessageId", "Status");
+
+ b.ToTable("ThinkingActivities", "public");
+ });
+
+ modelBuilder.Entity("Domain.Entities.Thought", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Content")
+ .IsRequired()
+ .HasMaxLength(10000)
+ .HasColumnType("character varying(10000)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("IsDeleted")
+ .HasColumnType("boolean");
+
+ b.Property("Metadata")
+ .HasMaxLength(2000)
+ .HasColumnType("character varying(2000)");
+
+ b.Property("StepNumber")
+ .HasColumnType("integer");
+
+ b.Property("ThinkingActivityId")
+ .HasColumnType("uuid");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CreatedAt");
+
+ b.HasIndex("ThinkingActivityId");
+
+ b.HasIndex("Type");
+
+ b.HasIndex("ThinkingActivityId", "StepNumber")
+ .IsUnique();
+
+ b.ToTable("Thoughts", "public");
+ });
+
+ modelBuilder.Entity("Domain.Entities.User", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AvatarUrl")
+ .HasColumnType("text");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("FullName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("IsDeleted")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false);
+
+ b.Property("PhoneNumber")
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("Roles")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UpdatedAt")
+ .IsRequired()
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Email")
+ .IsUnique();
+
+ b.HasIndex("IsDeleted");
+
+ b.ToTable("Users", "public");
+ });
+
+ modelBuilder.Entity("Domain.Entities.UserExternalLogin", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AvatarUrl")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("FirstName")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("IsDeleted")
+ .HasColumnType("boolean");
+
+ b.Property("LastName")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("Provider")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("ProviderKey")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("IX_UserExternalLogins_UserId");
+
+ b.HasIndex("Provider", "ProviderKey")
+ .IsUnique()
+ .HasDatabaseName("IX_UserExternalLogins_Provider_ProviderKey");
+
+ b.ToTable("UserExternalLogins", "public");
+ });
+
+ modelBuilder.Entity("Infrastructure.Identity.ApplicationRole", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("text");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Description")
+ .HasColumnType("text");
+
+ b.Property("Name")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("NormalizedName")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedName")
+ .IsUnique()
+ .HasDatabaseName("RoleNameIndex");
+
+ b.ToTable("Roles", "identity");
+ });
+
+ modelBuilder.Entity("Infrastructure.Identity.ApplicationUser", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AccessFailedCount")
+ .HasColumnType("integer");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("text");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Email")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("EmailConfirmed")
+ .HasColumnType("boolean");
+
+ b.Property("FirstName")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("FullName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("IsDeleted")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false);
+
+ b.Property("LastName")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("LockoutEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("LockoutEnd")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("NormalizedEmail")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("NormalizedUserName")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("PasswordHash")
+ .HasColumnType("text");
+
+ b.Property("PhoneNumber")
+ .HasColumnType("text");
+
+ b.Property("PhoneNumberConfirmed")
+ .HasColumnType("boolean");
+
+ b.Property("SecurityStamp")
+ .HasColumnType("text");
+
+ b.Property("TwoFactorEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserName")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedEmail")
+ .HasDatabaseName("EmailIndex");
+
+ b.HasIndex("NormalizedUserName")
+ .IsUnique()
+ .HasDatabaseName("UserNameIndex");
+
+ b.ToTable("Users", "identity");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("text");
+
+ b.Property("ClaimValue")
+ .HasColumnType("text");
+
+ b.Property("RoleId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("RoleClaims", "identity");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("text");
+
+ b.Property("ClaimValue")
+ .HasColumnType("text");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("UserClaims", "identity");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.Property("LoginProvider")
+ .HasColumnType("text");
+
+ b.Property("ProviderKey")
+ .HasColumnType("text");
+
+ b.Property("ProviderDisplayName")
+ .HasColumnType("text");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("LoginProvider", "ProviderKey");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("UserLogins", "identity");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.Property("RoleId")
+ .HasColumnType("uuid");
+
+ b.HasKey("UserId", "RoleId");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("UserRoles", "identity");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.Property("LoginProvider")
+ .HasColumnType("text");
+
+ b.Property("Name")
+ .HasColumnType("text");
+
+ b.Property("Value")
+ .HasColumnType("text");
+
+ b.HasKey("UserId", "LoginProvider", "Name");
+
+ b.ToTable("UserTokens", "identity");
+ });
+
+ modelBuilder.Entity("Domain.Entities.Conversation", b =>
+ {
+ b.HasOne("Domain.Entities.User", "Owner")
+ .WithMany()
+ .HasForeignKey("OwnerId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("Domain.Entities.User", null)
+ .WithMany("OwnedConversations")
+ .HasForeignKey("UserId");
+
+ b.Navigation("Owner");
+ });
+
+ modelBuilder.Entity("Domain.Entities.Message", b =>
+ {
+ b.HasOne("Domain.Entities.Conversation", "Conversation")
+ .WithMany("Messages")
+ .HasForeignKey("ConversationId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Domain.Entities.User", "Sender")
+ .WithMany()
+ .HasForeignKey("SenderId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Conversation");
+
+ b.Navigation("Sender");
+ });
+
+ modelBuilder.Entity("Domain.Entities.RefreshToken", b =>
+ {
+ b.HasOne("Domain.Entities.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Domain.Entities.Report", b =>
+ {
+ b.HasOne("Domain.Entities.Message", "Message")
+ .WithMany("Reports")
+ .HasForeignKey("MessageId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Domain.Entities.User", "Reporter")
+ .WithMany()
+ .HasForeignKey("ReporterId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Message");
+
+ b.Navigation("Reporter");
+ });
+
+ modelBuilder.Entity("Domain.Entities.ThinkingActivity", b =>
+ {
+ b.HasOne("Domain.Entities.Message", "Message")
+ .WithOne("ThinkingActivity")
+ .HasForeignKey("Domain.Entities.ThinkingActivity", "MessageId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Message");
+ });
+
+ modelBuilder.Entity("Domain.Entities.Thought", b =>
+ {
+ b.HasOne("Domain.Entities.ThinkingActivity", "ThinkingActivity")
+ .WithMany("Thoughts")
+ .HasForeignKey("ThinkingActivityId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("ThinkingActivity");
+ });
+
+ modelBuilder.Entity("Domain.Entities.UserExternalLogin", b =>
+ {
+ b.HasOne("Domain.Entities.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
+ {
+ b.HasOne("Infrastructure.Identity.ApplicationRole", null)
+ .WithMany()
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.HasOne("Infrastructure.Identity.ApplicationUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.HasOne("Infrastructure.Identity.ApplicationUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b =>
+ {
+ b.HasOne("Infrastructure.Identity.ApplicationRole", null)
+ .WithMany()
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Infrastructure.Identity.ApplicationUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.HasOne("Infrastructure.Identity.ApplicationUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Domain.Entities.Conversation", b =>
+ {
+ b.Navigation("Messages");
+ });
+
+ modelBuilder.Entity("Domain.Entities.Message", b =>
+ {
+ b.Navigation("Reports");
+
+ b.Navigation("ThinkingActivity");
+ });
+
+ modelBuilder.Entity("Domain.Entities.ThinkingActivity", b =>
+ {
+ b.Navigation("Thoughts");
+ });
+
+ modelBuilder.Entity("Domain.Entities.User", b =>
+ {
+ b.Navigation("OwnedConversations");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/Infrastructure/Data/Migrations/20251205100156_AddReportTable.cs b/src/Infrastructure/Data/Migrations/20251205100156_AddReportTable.cs
new file mode 100644
index 0000000..12f7885
--- /dev/null
+++ b/src/Infrastructure/Data/Migrations/20251205100156_AddReportTable.cs
@@ -0,0 +1,88 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Infrastructure.Data.Migrations
+{
+ ///
+ public partial class AddReportTable : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "Reports",
+ schema: "public",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ MessageId = table.Column(type: "uuid", nullable: false),
+ ReporterId = table.Column(type: "uuid", nullable: false),
+ Category = table.Column(type: "text", nullable: false),
+ Reason = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true),
+ Status = table.Column(type: "character varying(50)", maxLength: 50, nullable: false, defaultValue: "pending"),
+ CreatedAt = table.Column(type: "timestamp with time zone", nullable: false),
+ UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true),
+ IsDeleted = table.Column(type: "boolean", nullable: false),
+ DeletedAt = table.Column(type: "timestamp with time zone", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Reports", x => x.Id);
+ table.ForeignKey(
+ name: "FK_Reports_Messages_MessageId",
+ column: x => x.MessageId,
+ principalSchema: "public",
+ principalTable: "Messages",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ table.ForeignKey(
+ name: "FK_Reports_Users_ReporterId",
+ column: x => x.ReporterId,
+ principalSchema: "public",
+ principalTable: "Users",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Restrict);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Reports_Category",
+ schema: "public",
+ table: "Reports",
+ column: "Category");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Reports_CreatedAt",
+ schema: "public",
+ table: "Reports",
+ column: "CreatedAt");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Reports_MessageId",
+ schema: "public",
+ table: "Reports",
+ column: "MessageId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Reports_ReporterId",
+ schema: "public",
+ table: "Reports",
+ column: "ReporterId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Reports_Status",
+ schema: "public",
+ table: "Reports",
+ column: "Status");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "Reports",
+ schema: "public");
+ }
+ }
+}
diff --git a/src/Infrastructure/Data/Migrations/DataContextModelSnapshot.cs b/src/Infrastructure/Data/Migrations/DataContextModelSnapshot.cs
index f39d08d..024d47c 100644
--- a/src/Infrastructure/Data/Migrations/DataContextModelSnapshot.cs
+++ b/src/Infrastructure/Data/Migrations/DataContextModelSnapshot.cs
@@ -203,6 +203,60 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.ToTable("RefreshTokens", "public");
});
+ modelBuilder.Entity("Domain.Entities.Report", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Category")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("IsDeleted")
+ .HasColumnType("boolean");
+
+ b.Property("MessageId")
+ .HasColumnType("uuid");
+
+ b.Property("Reason")
+ .HasMaxLength(1000)
+ .HasColumnType("character varying(1000)");
+
+ b.Property("ReporterId")
+ .HasColumnType("uuid");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasDefaultValue("pending");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Category");
+
+ b.HasIndex("CreatedAt");
+
+ b.HasIndex("MessageId");
+
+ b.HasIndex("ReporterId");
+
+ b.HasIndex("Status");
+
+ b.ToTable("Reports", "public");
+ });
+
modelBuilder.Entity("Domain.Entities.ThinkingActivity", b =>
{
b.Property("Id")
@@ -686,6 +740,25 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Navigation("User");
});
+ modelBuilder.Entity("Domain.Entities.Report", b =>
+ {
+ b.HasOne("Domain.Entities.Message", "Message")
+ .WithMany("Reports")
+ .HasForeignKey("MessageId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Domain.Entities.User", "Reporter")
+ .WithMany()
+ .HasForeignKey("ReporterId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Message");
+
+ b.Navigation("Reporter");
+ });
+
modelBuilder.Entity("Domain.Entities.ThinkingActivity", b =>
{
b.HasOne("Domain.Entities.Message", "Message")
@@ -777,6 +850,8 @@ protected override void BuildModel(ModelBuilder modelBuilder)
modelBuilder.Entity("Domain.Entities.Message", b =>
{
+ b.Navigation("Reports");
+
b.Navigation("ThinkingActivity");
});
diff --git a/src/Infrastructure/Extensions/ServiceCollectionExtensions.cs b/src/Infrastructure/Extensions/ServiceCollectionExtensions.cs
index f091d8c..08afa58 100644
--- a/src/Infrastructure/Extensions/ServiceCollectionExtensions.cs
+++ b/src/Infrastructure/Extensions/ServiceCollectionExtensions.cs
@@ -96,6 +96,7 @@ private static IServiceCollection AddRepositories(this IServiceCollection servic
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
services.AddScoped();
services.AddScoped();
services.AddScoped();
diff --git a/src/Infrastructure/Repositories/ReportRepository.cs b/src/Infrastructure/Repositories/ReportRepository.cs
new file mode 100644
index 0000000..f61c9aa
--- /dev/null
+++ b/src/Infrastructure/Repositories/ReportRepository.cs
@@ -0,0 +1,78 @@
+using Application.Common.Models;
+using Application.Interfaces.Repositories;
+using Domain.Entities;
+using Infrastructure.Data.Contexts;
+using Microsoft.EntityFrameworkCore;
+
+namespace Infrastructure.Repositories;
+
+///
+/// Implementation of the Report repository.
+///
+public class ReportRepository : IReportRepository
+{
+ private readonly DataContext _context;
+
+ public ReportRepository(DataContext context)
+ {
+ _context = context ?? throw new ArgumentNullException(nameof(context));
+ }
+
+ public async Task> GetListAsync(
+ PaginationRequest paginationRequest,
+ CancellationToken cancellationToken = default)
+ {
+ var reports = await _context.Reports
+ .Include(r => r.Message)
+ .Include(r => r.Reporter)
+ .OrderByDescending(r => r.CreatedAt)
+ .Skip((paginationRequest.PageNumber - 1) * paginationRequest.PageSize)
+ .Take(paginationRequest.PageSize)
+ .ToListAsync(cancellationToken);
+
+ var totalCount = await _context.Reports.CountAsync(cancellationToken);
+
+ return new PaginatedResult
+ {
+ Items = reports,
+ TotalCount = totalCount,
+ PageNumber = paginationRequest.PageNumber,
+ PageSize = paginationRequest.PageSize
+ };
+ }
+
+ public async Task AddAsync(Report report, CancellationToken cancellationToken = default)
+ {
+ await _context.Reports.AddAsync(report, cancellationToken);
+ }
+
+ public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
+ {
+ return await _context.Reports
+ .Include(r => r.Message)
+ .Include(r => r.Reporter)
+ .FirstOrDefaultAsync(r => r.Id == id, cancellationToken);
+ }
+
+ public async Task> GetByMessageIdAsync(
+ Guid messageId,
+ CancellationToken cancellationToken = default)
+ {
+ return await _context.Reports
+ .Where(r => r.MessageId == messageId)
+ .Include(r => r.Reporter)
+ .OrderByDescending(r => r.CreatedAt)
+ .ToListAsync(cancellationToken);
+ }
+
+ public async Task> GetByReporterIdAsync(
+ Guid reporterId,
+ CancellationToken cancellationToken = default)
+ {
+ return await _context.Reports
+ .Where(r => r.ReporterId == reporterId)
+ .Include(r => r.Message)
+ .OrderByDescending(r => r.CreatedAt)
+ .ToListAsync(cancellationToken);
+ }
+}
diff --git a/src/Web.Api/Controllers/V1/MessageController.cs b/src/Web.Api/Controllers/V1/MessageController.cs
index e814e46..396ff30 100644
--- a/src/Web.Api/Controllers/V1/MessageController.cs
+++ b/src/Web.Api/Controllers/V1/MessageController.cs
@@ -1,4 +1,5 @@
using Application.Features.Message.Delete;
+using Application.Features.Message.Report;
using Application.Features.Message.Update;
using MediatR;
using Microsoft.AspNetCore.Authorization;
@@ -52,4 +53,23 @@ public async Task DeleteMessage(Guid id)
var result = await _mediator.Send(command);
return HandleResult(result);
}
+
+ ///
+ /// Report a message
+ ///
+ /// Message ID to report
+ /// Report message request
+ /// Report information
+ [HttpPost("{id:guid}/report")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiResponse