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), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status403Forbidden)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task ReportMessage(Guid id, [FromBody] ReportMessageCommand request) + { + var fullRequest = request with { MessageId = id }; + var result = await _mediator.Send(fullRequest); + return HandleResult(result); + } }