diff --git a/WSIST/WSIST.Engine/Feedback.cs b/WSIST/WSIST.Engine/Feedback.cs new file mode 100644 index 0000000..76fd13a --- /dev/null +++ b/WSIST/WSIST.Engine/Feedback.cs @@ -0,0 +1,29 @@ +namespace WSIST.Engine; + +public class Feedback +{ + public int Id { get; set; } + public int UserId { get; set; } + public User? User { get; set; } + public required string Message { get; set; } + public FeedbackCategory Category { get; set; } + public FeedbackStatus Status { get; set; } = FeedbackStatus.Open; + + // Default so a Feedback created without an explicit timestamp never persists + // DateTime.MinValue (0001-01-01) — mirrors User.CreatedAt. + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public enum FeedbackCategory + { + Bug = 0, + Feature = 1, + Other = 2, + } + + public enum FeedbackStatus + { + Open = 0, + Reviewed = 1, + Closed = 2, + } +} diff --git a/WSIST/WSIST.Engine/FeedbackManagement.cs b/WSIST/WSIST.Engine/FeedbackManagement.cs new file mode 100644 index 0000000..5e6ccad --- /dev/null +++ b/WSIST/WSIST.Engine/FeedbackManagement.cs @@ -0,0 +1,110 @@ +using Microsoft.EntityFrameworkCore; + +namespace WSIST.Engine; + +public class FeedbackManagement +{ + private readonly WsistContext context; + + public FeedbackManagement(WsistContext context) + { + this.context = context; + } + + public Feedback Submit(int userId, string message, Feedback.FeedbackCategory category) + { + var trimmed = message?.Trim() ?? string.Empty; + if (trimmed.Length == 0) + throw new ArgumentException("Feedback message cannot be empty.", nameof(message)); + + // Defensively cap to the column length so an over-long message surfaces + // as a validation error rather than a DbUpdateException at SaveChanges. + if (trimmed.Length > 4000) + throw new ArgumentException( + "Feedback message is too long (4000 characters max).", + nameof(message) + ); + + if (!Enum.IsDefined(category)) + throw new ArgumentException("Unknown feedback category.", nameof(category)); + + var feedback = new Feedback + { + UserId = userId, + Message = trimmed, + Category = category, + Status = Feedback.FeedbackStatus.Open, + CreatedAt = DateTime.UtcNow, + }; + context.Feedbacks.Add(feedback); + context.SaveChanges(); + return feedback; + } + + // A user's own submissions (newest first) for their feedback history. + public List GetForUser(int userId) + { + return context + .Feedbacks.AsNoTracking() + .Where(f => f.UserId == userId) + .OrderByDescending(f => f.CreatedAt) + .ThenByDescending(f => f.Id) + .Select(f => new FeedbackView( + f.Id, + f.Message, + f.Category, + f.Status, + f.CreatedAt, + f.User != null ? f.User.DisplayName : "(unknown)", + f.User != null ? f.User.Email : "" + )) + .ToList(); + } + + // Admin-only listing. Authorization (is the caller the owner?) is enforced + // by the page that calls this — the engine intentionally has no notion of + // who the admin is, so it stays config-driven in the web layer. + public List GetAll() + { + return context + .Feedbacks.AsNoTracking() + .OrderByDescending(f => f.CreatedAt) + // Tie-break on Id so equal timestamps order stably. + .ThenByDescending(f => f.Id) + .Select(f => new FeedbackView( + f.Id, + f.Message, + f.Category, + f.Status, + f.CreatedAt, + f.User != null ? f.User.DisplayName : "(unknown)", + f.User != null ? f.User.Email : "" + )) + .ToList(); + } + + // Update a submission's workflow status (Open/Reviewed/Closed). Admin-only; + // the calling page enforces that. Returns false if the row no longer exists. + public bool UpdateStatus(int feedbackId, Feedback.FeedbackStatus status) + { + if (!Enum.IsDefined(status)) + throw new ArgumentException("Unknown feedback status.", nameof(status)); + + var feedback = context.Feedbacks.Find(feedbackId); + if (feedback is null) + return false; + feedback.Status = status; + context.SaveChanges(); + return true; + } + + public record FeedbackView( + int Id, + string Message, + Feedback.FeedbackCategory Category, + Feedback.FeedbackStatus Status, + DateTime CreatedAt, + string SubmittedByName, + string SubmittedByEmail + ); +} diff --git a/WSIST/WSIST.Engine/Migrations/20260617062848_AddFeedbackTable.Designer.cs b/WSIST/WSIST.Engine/Migrations/20260617062848_AddFeedbackTable.Designer.cs new file mode 100644 index 0000000..c9f2f39 --- /dev/null +++ b/WSIST/WSIST.Engine/Migrations/20260617062848_AddFeedbackTable.Designer.cs @@ -0,0 +1,246 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using WSIST.Engine; + +#nullable disable + +namespace WSIST.Engine.Migrations +{ + [DbContext(typeof(WsistContext))] + [Migration("20260617062848_AddFeedbackTable")] + partial class AddFeedbackTable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.17") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("WSIST.Engine.Feedback", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Category") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("UserId"); + + b.ToTable("Feedbacks", (string)null); + }); + + modelBuilder.Entity("WSIST.Engine.Subject", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("IsSystem") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)") + .UseCollation("utf8mb4_general_ci"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Name") + .IsUnique(); + + b.ToTable("Subjects", (string)null); + + b.HasData( + new + { + Id = -6, + IsSystem = true, + Name = "Math" + }, + new + { + Id = -5, + IsSystem = true, + Name = "English" + }, + new + { + Id = -4, + IsSystem = true, + Name = "French" + }, + new + { + Id = -3, + IsSystem = true, + Name = "German" + }, + new + { + Id = -2, + IsSystem = true, + Name = "Chemistry" + }, + new + { + Id = -1, + IsSystem = true, + Name = "Other" + }); + }); + + modelBuilder.Entity("WSIST.Engine.Test", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("DueDate") + .HasColumnType("date"); + + b.Property("Grade") + .HasColumnType("double"); + + b.Property("Subject") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("Understanding") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("Volume") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Subject"); + + b.HasIndex("UserId"); + + b.ToTable("Tests", (string)null); + }); + + modelBuilder.Entity("WSIST.Engine.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("GoogleId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("WSIST.Engine.Feedback", b => + { + b.HasOne("WSIST.Engine.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("WSIST.Engine.Subject", b => + { + b.HasOne("WSIST.Engine.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("User"); + }); + + modelBuilder.Entity("WSIST.Engine.Test", b => + { + b.HasOne("WSIST.Engine.Subject", null) + .WithMany() + .HasForeignKey("Subject") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("WSIST.Engine.User", "User") + .WithMany("Tests") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("WSIST.Engine.User", b => + { + b.Navigation("Tests"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/WSIST/WSIST.Engine/Migrations/20260617062848_AddFeedbackTable.cs b/WSIST/WSIST.Engine/Migrations/20260617062848_AddFeedbackTable.cs new file mode 100644 index 0000000..d675ade --- /dev/null +++ b/WSIST/WSIST.Engine/Migrations/20260617062848_AddFeedbackTable.cs @@ -0,0 +1,58 @@ +using System; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace WSIST.Engine.Migrations +{ + /// + public partial class AddFeedbackTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Feedbacks", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + UserId = table.Column(type: "int", nullable: false), + Message = table.Column(type: "varchar(4000)", maxLength: 4000, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + Category = table.Column(type: "int", nullable: false), + Status = table.Column(type: "int", nullable: false), + CreatedAt = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Feedbacks", x => x.Id); + table.ForeignKey( + name: "FK_Feedbacks_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_Feedbacks_CreatedAt", + table: "Feedbacks", + column: "CreatedAt"); + + migrationBuilder.CreateIndex( + name: "IX_Feedbacks_UserId", + table: "Feedbacks", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Feedbacks"); + } + } +} diff --git a/WSIST/WSIST.Engine/Migrations/20260617064345_AddUserPreferredLanguage.Designer.cs b/WSIST/WSIST.Engine/Migrations/20260617064345_AddUserPreferredLanguage.Designer.cs new file mode 100644 index 0000000..ca6af24 --- /dev/null +++ b/WSIST/WSIST.Engine/Migrations/20260617064345_AddUserPreferredLanguage.Designer.cs @@ -0,0 +1,250 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using WSIST.Engine; + +#nullable disable + +namespace WSIST.Engine.Migrations +{ + [DbContext(typeof(WsistContext))] + [Migration("20260617064345_AddUserPreferredLanguage")] + partial class AddUserPreferredLanguage + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.17") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("WSIST.Engine.Feedback", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Category") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("UserId"); + + b.ToTable("Feedbacks", (string)null); + }); + + modelBuilder.Entity("WSIST.Engine.Subject", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("IsSystem") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)") + .UseCollation("utf8mb4_general_ci"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Name") + .IsUnique(); + + b.ToTable("Subjects", (string)null); + + b.HasData( + new + { + Id = -6, + IsSystem = true, + Name = "Math" + }, + new + { + Id = -5, + IsSystem = true, + Name = "English" + }, + new + { + Id = -4, + IsSystem = true, + Name = "French" + }, + new + { + Id = -3, + IsSystem = true, + Name = "German" + }, + new + { + Id = -2, + IsSystem = true, + Name = "Chemistry" + }, + new + { + Id = -1, + IsSystem = true, + Name = "Other" + }); + }); + + modelBuilder.Entity("WSIST.Engine.Test", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("DueDate") + .HasColumnType("date"); + + b.Property("Grade") + .HasColumnType("double"); + + b.Property("Subject") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("Understanding") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("Volume") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Subject"); + + b.HasIndex("UserId"); + + b.ToTable("Tests", (string)null); + }); + + modelBuilder.Entity("WSIST.Engine.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("GoogleId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("PreferredLanguage") + .HasMaxLength(5) + .HasColumnType("varchar(5)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("WSIST.Engine.Feedback", b => + { + b.HasOne("WSIST.Engine.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("WSIST.Engine.Subject", b => + { + b.HasOne("WSIST.Engine.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("User"); + }); + + modelBuilder.Entity("WSIST.Engine.Test", b => + { + b.HasOne("WSIST.Engine.Subject", null) + .WithMany() + .HasForeignKey("Subject") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("WSIST.Engine.User", "User") + .WithMany("Tests") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("WSIST.Engine.User", b => + { + b.Navigation("Tests"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/WSIST/WSIST.Engine/Migrations/20260617064345_AddUserPreferredLanguage.cs b/WSIST/WSIST.Engine/Migrations/20260617064345_AddUserPreferredLanguage.cs new file mode 100644 index 0000000..79f22e8 --- /dev/null +++ b/WSIST/WSIST.Engine/Migrations/20260617064345_AddUserPreferredLanguage.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace WSIST.Engine.Migrations +{ + /// + public partial class AddUserPreferredLanguage : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "PreferredLanguage", + table: "Users", + type: "varchar(5)", + maxLength: 5, + nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "PreferredLanguage", + table: "Users"); + } + } +} diff --git a/WSIST/WSIST.Engine/Migrations/WsistContextModelSnapshot.cs b/WSIST/WSIST.Engine/Migrations/WsistContextModelSnapshot.cs index 673854f..6cb7140 100644 --- a/WSIST/WSIST.Engine/Migrations/WsistContextModelSnapshot.cs +++ b/WSIST/WSIST.Engine/Migrations/WsistContextModelSnapshot.cs @@ -22,6 +22,40 @@ protected override void BuildModel(ModelBuilder modelBuilder) MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + modelBuilder.Entity("WSIST.Engine.Feedback", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Category") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("UserId"); + + b.ToTable("Feedbacks", (string)null); + }); + modelBuilder.Entity("WSIST.Engine.Subject", b => { b.Property("Id") @@ -153,6 +187,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(100) .HasColumnType("varchar(100)"); + b.Property("PreferredLanguage") + .HasMaxLength(5) + .HasColumnType("varchar(5)"); + b.HasKey("Id"); b.HasIndex("Email") @@ -161,6 +199,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Users", (string)null); }); + modelBuilder.Entity("WSIST.Engine.Feedback", b => + { + b.HasOne("WSIST.Engine.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("WSIST.Engine.Subject", b => { b.HasOne("WSIST.Engine.User", "User") diff --git a/WSIST/WSIST.Engine/TestExporter.cs b/WSIST/WSIST.Engine/TestExporter.cs new file mode 100644 index 0000000..7f739e5 --- /dev/null +++ b/WSIST/WSIST.Engine/TestExporter.cs @@ -0,0 +1,78 @@ +using System.Globalization; +using System.Text; + +namespace WSIST.Engine; + +/// +/// One row of a user's data export. Holds typed values so JSON keeps real +/// types (ISO date, numeric grade) while CSV formats them itself. +/// +public record TestExportRow( + string Title, + string Subject, + DateOnly DueDate, + string Volume, + string Understanding, + double? Grade +); + +public static class TestExporter +{ + private static readonly string[] Header = + [ + "Title", + "Subject", + "DueDate", + "Volume", + "Understanding", + "Grade", + ]; + + /// + /// Serialises export rows to RFC 4180 CSV (CRLF line endings, quoted + /// fields where needed) with formula-injection mitigation on the free-text + /// columns. + /// + public static string ToCsv(IReadOnlyList rows) + { + var sb = new StringBuilder(); + sb.Append(string.Join(',', Header.Select(Field))).Append("\r\n"); + + foreach (var r in rows) + { + sb.Append(Field(r.Title)) + .Append(',') + .Append(Field(r.Subject)) + .Append(',') + .Append(r.DueDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)) + .Append(',') + .Append(Field(r.Volume)) + .Append(',') + .Append(Field(r.Understanding)) + .Append(',') + .Append(r.Grade?.ToString(CultureInfo.InvariantCulture) ?? "") + .Append("\r\n"); + } + + return sb.ToString(); + } + + private static string Field(string value) + { + var v = value ?? ""; + + // Formula-injection mitigation: a cell beginning with =, +, -, @, or a + // control character can be executed by spreadsheet apps. Prefix with a + // single quote so it is treated as literal text. Titles and custom + // subject names are user-controlled, so this matters. + if (v.Length > 0 && v[0] is '=' or '+' or '-' or '@' or '\t' or '\r') + v = "'" + v; + + // RFC 4180: quote fields containing the delimiter, a quote, or a newline, + // doubling any embedded quotes. + if (v.Contains('"') || v.Contains(',') || v.Contains('\n') || v.Contains('\r')) + v = "\"" + v.Replace("\"", "\"\"") + "\""; + + return v; + } +} diff --git a/WSIST/WSIST.Engine/TestManagement.cs b/WSIST/WSIST.Engine/TestManagement.cs index f79ba66..92298c1 100644 --- a/WSIST/WSIST.Engine/TestManagement.cs +++ b/WSIST/WSIST.Engine/TestManagement.cs @@ -1,3 +1,5 @@ +using Microsoft.EntityFrameworkCore; + namespace WSIST.Engine; public class TestManagement @@ -185,6 +187,30 @@ public void UpdateDisplayName(int userId, string displayName) context.SaveChanges(); } + public void UpdatePreferredLanguage(int userId, string? language) + { + var user = context.Users.Find(userId); + if (user is null) + return; + user.PreferredLanguage = language; + context.SaveChanges(); + } + + // Used by the request-localization provider to resolve a signed-in user's + // stored language without materializing the whole User entity. Async so the + // provider (which runs first on every authenticated request) never blocks on + // database I/O. + public Task GetPreferredLanguageByEmailAsync( + string email, + CancellationToken cancellationToken = default + ) + { + return context + .Users.Where(u => u.Email == email) + .Select(u => u.PreferredLanguage) + .FirstOrDefaultAsync(cancellationToken); + } + public User GetOrCreateUser(string email, string displayName, string googleId) { if (string.IsNullOrWhiteSpace(email)) @@ -257,4 +283,32 @@ public List GetGradeAverages(int userId) .OrderBy(x => x.SubjectName) .ToList(); } + + /// + /// Every test belonging to the user — past and future, graded or not — + /// shaped for a data-portability export with subject names resolved and + /// enum values rendered to readable text. Strictly scoped to + /// : another user's rows can never appear. + /// + public List GetTestExport(int userId) + { + var subjects = context + .Subjects.Where(s => s.IsSystem || s.UserId == userId) + .ToDictionary(s => s.Id, s => s.Name); + + return context + .Tests.Where(t => t.UserId == userId) + .AsEnumerable() + .OrderBy(t => t.DueDate) + .ThenBy(t => t.Title) + .Select(t => new TestExportRow( + t.Title, + subjects.TryGetValue(t.Subject, out var name) ? name : t.Subject.ToString(), + t.DueDate, + Test.VolumeHelper(t.Volume), + Test.UnderstandingHelper(t.Understanding), + t.Grade + )) + .ToList(); + } } diff --git a/WSIST/WSIST.Engine/User.cs b/WSIST/WSIST.Engine/User.cs index c35262e..11a1eda 100644 --- a/WSIST/WSIST.Engine/User.cs +++ b/WSIST/WSIST.Engine/User.cs @@ -7,6 +7,11 @@ public class User public string DisplayName { get; set; } = string.Empty; public string GoogleId { get; set; } = string.Empty; + // Two-letter culture code ("en"/"de") for the UI language. Null until the + // user explicitly picks one — while null the language is derived from the + // browser's Accept-Language header on each request. + public string? PreferredLanguage { get; set; } + // Default so a User created without an explicit timestamp never persists // DateTime.MinValue (0001-01-01). public DateTime CreatedAt { get; set; } = DateTime.UtcNow; diff --git a/WSIST/WSIST.Engine/WsistContext.cs b/WSIST/WSIST.Engine/WsistContext.cs index 5357ee2..c30c2bd 100644 --- a/WSIST/WSIST.Engine/WsistContext.cs +++ b/WSIST/WSIST.Engine/WsistContext.cs @@ -10,6 +10,7 @@ public WsistContext(DbContextOptions options) public DbSet Tests { get; set; } public DbSet Users { get; set; } public DbSet Subjects { get; set; } + public DbSet Feedbacks { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -111,6 +112,27 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) ); }); + modelBuilder.Entity(entity => + { + entity.ToTable("Feedbacks"); + entity.HasKey(e => e.Id); + entity.Property(e => e.Id).ValueGeneratedOnAdd(); + entity.Property(e => e.Message).HasMaxLength(4000).IsRequired(); + // Store enums as ints, consistent with Test.Volume/Understanding. + entity.Property(e => e.Category).HasConversion(); + entity.Property(e => e.Status).HasConversion(); + + // Deleting a user removes their feedback too (they own the rows). + entity + .HasOne(f => f.User) + .WithMany() + .HasForeignKey(f => f.UserId) + .OnDelete(DeleteBehavior.Cascade); + + // The admin listing orders newest-first; index the sort column. + entity.HasIndex(e => e.CreatedAt); + }); + modelBuilder.Entity(entity => { entity.ToTable("Users"); @@ -118,6 +140,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.HasIndex(e => e.Email).IsUnique(); entity.Property(e => e.GoogleId).HasMaxLength(100); entity.Property(e => e.DisplayName).HasMaxLength(100); + entity.Property(e => e.PreferredLanguage).HasMaxLength(5); }); } } diff --git a/WSIST/WSIST.UnitTests/UnitTests.cs b/WSIST/WSIST.UnitTests/UnitTests.cs index c889b12..a71ecf7 100644 --- a/WSIST/WSIST.UnitTests/UnitTests.cs +++ b/WSIST/WSIST.UnitTests/UnitTests.cs @@ -540,4 +540,277 @@ public void AddCustomSubject_AllowsSameNameForDifferentUsers() //assert Assert.That(created.UserId, Is.EqualTo(otherUser.Id)); } + + [Test] + public void GetTestExport_OnlyIncludesRequestingUsersTests() + { + //arrange + using var context = CreateContext(); + var owner = SeedUser(context); + var other = new User + { + Email = "other@example.com", + DisplayName = "Other", + GoogleId = "google-456", + CreatedAt = DateTime.UtcNow, + }; + context.Users.Add(other); + context.SaveChanges(); + var subjectId = SeedSystemSubject(context); + var manager = new TestManagement(context); + manager.NewTestMaker( + "Owner Test", + subjectId, + new DateOnly(2026, 12, 01), + Test.TestVolume.Medium, + Test.PersonalUnderstanding.Medium, + null, + owner.Id + ); + manager.NewTestMaker( + "Other Test", + subjectId, + new DateOnly(2026, 12, 01), + Test.TestVolume.Medium, + Test.PersonalUnderstanding.Medium, + null, + other.Id + ); + + //act + var export = manager.GetTestExport(owner.Id); + + //assert — the export is strictly scoped to the requesting user + Assert.That(export.Any(r => r.Title == "Owner Test")); + Assert.That(export.Any(r => r.Title == "Other Test"), Is.False); + } + + [Test] + public void GetTestExport_IncludesPastGradedTestsWithResolvedSubjectName() + { + //arrange + using var context = CreateContext(); + var user = SeedUser(context); + var subjectId = SeedSystemSubject(context, "Math"); + var manager = new TestManagement(context); + manager.NewTestMaker( + "Past Exam", + subjectId, + new DateOnly(2025, 01, 01), + Test.TestVolume.High, + Test.PersonalUnderstanding.Low, + 5.5, + user.Id + ); + + //act + var export = manager.GetTestExport(user.Id); + + //assert + var row = export.Single(r => r.Title == "Past Exam"); + Assert.That(row.Subject, Is.EqualTo("Math")); + Assert.That(row.Grade, Is.EqualTo(5.5)); + Assert.That(row.Volume, Is.EqualTo("High")); + } + + [Test] + public void ToCsv_WritesHeaderAndFormatsValues() + { + var rows = new List + { + new("Exam", "Math", new DateOnly(2026, 03, 01), "High", "Low", 5.5), + }; + + var csv = TestExporter.ToCsv(rows); + + Assert.That(csv, Does.StartWith("Title,Subject,DueDate,Volume,Understanding,Grade")); + Assert.That(csv, Does.Contain("2026-03-01")); + Assert.That(csv, Does.Contain("5.5")); + } + + [Test] + public void ToCsv_EscapesDelimitersAndQuotes() + { + var rows = new List + { + new("Title, comma", "Sub \"quote\"", new DateOnly(2026, 03, 01), "High", "Low", null), + }; + + var csv = TestExporter.ToCsv(rows); + + Assert.That(csv, Does.Contain("\"Title, comma\"")); + Assert.That(csv, Does.Contain("\"Sub \"\"quote\"\"\"")); + } + + [Test] + public void ToCsv_MitigatesFormulaInjection() + { + var rows = new List + { + new("=SUM(A1:A2)", "Math", new DateOnly(2026, 03, 01), "High", "Low", null), + }; + + var csv = TestExporter.ToCsv(rows); + + //a leading '=' must be neutralised so spreadsheets treat it as text + Assert.That(csv, Does.Contain("'=SUM(A1:A2)")); + } + + [Test] + public void SubmitFeedback_PersistsTrimmedOpenRow() + { + //arrange + using var context = CreateContext(); + var user = SeedUser(context); + var feedback = new FeedbackManagement(context); + + //act + var saved = feedback.Submit( + user.Id, + " Please add dark mode ", + Feedback.FeedbackCategory.Feature + ); + + //assert + Assert.That(saved.Id, Is.GreaterThan(0)); + var row = context.Feedbacks.Single(); + Assert.That(row.Message, Is.EqualTo("Please add dark mode")); + Assert.That(row.Category, Is.EqualTo(Feedback.FeedbackCategory.Feature)); + Assert.That(row.Status, Is.EqualTo(Feedback.FeedbackStatus.Open)); + Assert.That(row.UserId, Is.EqualTo(user.Id)); + } + + [Test] + public void SubmitFeedback_EmptyMessage_Throws() + { + using var context = CreateContext(); + var user = SeedUser(context); + var feedback = new FeedbackManagement(context); + + Assert.Throws(() => + feedback.Submit(user.Id, " ", Feedback.FeedbackCategory.Bug) + ); + Assert.That(context.Feedbacks.Any(), Is.False); + } + + [Test] + public void SubmitFeedback_TooLongMessage_Throws() + { + using var context = CreateContext(); + var user = SeedUser(context); + var feedback = new FeedbackManagement(context); + + var tooLong = new string('x', 4001); + Assert.Throws(() => + feedback.Submit(user.Id, tooLong, Feedback.FeedbackCategory.Bug) + ); + } + + [Test] + public void SubmitFeedback_UndefinedCategory_Throws() + { + using var context = CreateContext(); + var user = SeedUser(context); + var feedback = new FeedbackManagement(context); + + Assert.Throws(() => + feedback.Submit(user.Id, "Valid message", (Feedback.FeedbackCategory)99) + ); + } + + [Test] + public void GetAllFeedback_ReturnsNewestFirstWithSubmitterInfo() + { + //arrange + using var context = CreateContext(); + var user = SeedUser(context); + // Insert directly with explicit timestamps so ordering is deterministic + // (Submit stamps DateTime.UtcNow, which two quick calls could tie on). + context.Feedbacks.Add( + new Feedback + { + UserId = user.Id, + Message = "older", + Category = Feedback.FeedbackCategory.Bug, + CreatedAt = new DateTime(2026, 01, 01, 0, 0, 0, DateTimeKind.Utc), + } + ); + context.Feedbacks.Add( + new Feedback + { + UserId = user.Id, + Message = "newer", + Category = Feedback.FeedbackCategory.Feature, + CreatedAt = new DateTime(2026, 02, 01, 0, 0, 0, DateTimeKind.Utc), + } + ); + context.SaveChanges(); + var feedback = new FeedbackManagement(context); + + //act + var all = feedback.GetAll(); + + //assert — newest first, submitter name/email resolved + Assert.That(all, Has.Count.EqualTo(2)); + Assert.That(all[0].Message, Is.EqualTo("newer")); + Assert.That(all[1].Message, Is.EqualTo("older")); + Assert.That(all[0].SubmittedByName, Is.EqualTo("Test User")); + Assert.That(all[0].SubmittedByEmail, Is.EqualTo("test@example.com")); + } + + [Test] + public void UpdateStatus_ChangesStatusAndPersists() + { + //arrange + using var context = CreateContext(); + var user = SeedUser(context); + var feedback = new FeedbackManagement(context); + var saved = feedback.Submit(user.Id, "A bug", Feedback.FeedbackCategory.Bug); + Assert.That(saved.Status, Is.EqualTo(Feedback.FeedbackStatus.Open)); + + //act + var ok = feedback.UpdateStatus(saved.Id, Feedback.FeedbackStatus.Reviewed); + + //assert + Assert.That(ok, Is.True); + Assert.That( + context.Feedbacks.Single().Status, + Is.EqualTo(Feedback.FeedbackStatus.Reviewed) + ); + } + + [Test] + public void UpdateStatus_ReturnsFalseForMissingRow() + { + using var context = CreateContext(); + var feedback = new FeedbackManagement(context); + Assert.That(feedback.UpdateStatus(999, Feedback.FeedbackStatus.Closed), Is.False); + } + + [Test] + public void GetForUser_ReturnsOnlyTheUsersOwnFeedback() + { + //arrange + using var context = CreateContext(); + var user = SeedUser(context); + var other = new User + { + Email = "other@example.com", + DisplayName = "Other", + GoogleId = "google-456", + CreatedAt = DateTime.UtcNow, + }; + context.Users.Add(other); + context.SaveChanges(); + var feedback = new FeedbackManagement(context); + feedback.Submit(user.Id, "Mine", Feedback.FeedbackCategory.Bug); + feedback.Submit(other.Id, "Theirs", Feedback.FeedbackCategory.Other); + + //act + var mine = feedback.GetForUser(user.Id); + + //assert — strictly scoped to the requesting user + Assert.That(mine, Has.Count.EqualTo(1)); + Assert.That(mine[0].Message, Is.EqualTo("Mine")); + } } diff --git a/WSIST/WSIST.Web/Components/App.razor b/WSIST/WSIST.Web/Components/App.razor index c006301..209c3c8 100644 --- a/WSIST/WSIST.Web/Components/App.razor +++ b/WSIST/WSIST.Web/Components/App.razor @@ -1,5 +1,6 @@ - - +@using System.Globalization + + diff --git a/WSIST/WSIST.Web/Components/LanguageToggle.razor b/WSIST/WSIST.Web/Components/LanguageToggle.razor new file mode 100644 index 0000000..63d6dae --- /dev/null +++ b/WSIST/WSIST.Web/Components/LanguageToggle.razor @@ -0,0 +1,24 @@ +@using System.Globalization +@inject NavigationManager Nav +@inject IStringLocalizer Localizer + +@* EN / DE switch. Each link is a full (non-enhanced) navigation to the + set-language endpoint, which writes the culture cookie + saves the + preference, then redirects back to the current page. *@ +
+ EN + + DE +
+ +@code { + private bool IsGerman => CultureInfo.CurrentUICulture.TwoLetterISOLanguageName == "de"; + + // Local relative path (with leading slash) for the round-trip redirect. + private string CurrentPath => + Uri.EscapeDataString("/" + Nav.ToBaseRelativePath(Nav.Uri)); +} diff --git a/WSIST/WSIST.Web/Components/Pages/AuthenticatedComponentBase.cs b/WSIST/WSIST.Web/Components/Pages/AuthenticatedComponentBase.cs index 1f92567..e7c1a83 100644 --- a/WSIST/WSIST.Web/Components/Pages/AuthenticatedComponentBase.cs +++ b/WSIST/WSIST.Web/Components/Pages/AuthenticatedComponentBase.cs @@ -13,6 +13,11 @@ NavigationManager navigation { protected int CurrentUserId { get; private set; } + // The authenticated user's email claim, exposed so pages can do + // owner/admin gating (e.g. the feedback listing) without re-reading the + // auth state themselves. + protected string? CurrentUserEmail { get; private set; } + protected override async Task OnInitializedAsync() { var authState = await authStateProvider.GetAuthenticationStateAsync(); @@ -40,6 +45,7 @@ protected override async Task OnInitializedAsync() { var dbUser = management.GetOrCreateUser(email, name, googleId); CurrentUserId = dbUser.Id; + CurrentUserEmail = dbUser.Email; } catch (Exception) { diff --git a/WSIST/WSIST.Web/Components/Pages/Error.razor b/WSIST/WSIST.Web/Components/Pages/Error.razor index 48a6207..de27013 100644 --- a/WSIST/WSIST.Web/Components/Pages/Error.razor +++ b/WSIST/WSIST.Web/Components/Pages/Error.razor @@ -1,13 +1,14 @@ @page "/Error" @layout EmptyLayout @using WSIST.Web.Components.Layout -Error – WSIST +@inject IStringLocalizer Localizer +@Localizer["Error_Title"] – WSIST
-

Something went wrong

-

An unexpected error occurred. If this keeps happening, try logging out and back in.

- ← Back to home +

@Localizer["Error_Title"]

+

@Localizer["Error_Sub"]

+ ← @Localizer["Common_BackToHome"]
diff --git a/WSIST/WSIST.Web/Components/Pages/FeedbackPage.razor b/WSIST/WSIST.Web/Components/Pages/FeedbackPage.razor new file mode 100644 index 0000000..eda0f9e --- /dev/null +++ b/WSIST/WSIST.Web/Components/Pages/FeedbackPage.razor @@ -0,0 +1,122 @@ +@page "/feedback" +@rendermode @(new InteractiveServerRenderMode(prerender: false)) +@inherits AuthenticatedComponentBase +@using WSIST.Engine +WSIST — @localizer["Feedback_Title"] + +
+ + +
+
+

@localizer["Feedback_Title"]

+

@localizer["Feedback_Subtitle"]

+
+ +
+

@localizer["Feedback_Submit"]

+
+ +
+
+ +
+

@localizer["Feedback_YourSubmissions"]

+
+ @if (myFeedback.Count == 0) + { + + } + else + { + @foreach (var item in myFeedback) + { + + } + } +
+
+ + @if (isAdmin) + { +
+

@localizer["Feedback_AdminTitle"]

+
+ @if (allFeedback.Count == 0) + { + + } + else + { + @foreach (var item in allFeedback) + { + + } + } +
+
+ } +
+
diff --git a/WSIST/WSIST.Web/Components/Pages/FeedbackPage.razor.cs b/WSIST/WSIST.Web/Components/Pages/FeedbackPage.razor.cs new file mode 100644 index 0000000..e5e1ed8 --- /dev/null +++ b/WSIST/WSIST.Web/Components/Pages/FeedbackPage.razor.cs @@ -0,0 +1,98 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.Localization; +using WSIST.Engine; + +namespace WSIST.Web.Components.Pages; + +public partial class FeedbackPage( + TestManagement management, + FeedbackManagement feedbackManagement, + AuthenticationStateProvider authStateProvider, + NavigationManager navigation, + IConfiguration configuration, + IStringLocalizer localizer +) : AuthenticatedComponentBase(management, authStateProvider, navigation) +{ + private Feedback.FeedbackCategory category = Feedback.FeedbackCategory.Bug; + private string message = string.Empty; + private bool isSubmitting; + private string? submitError; + private string? submitMessage; + + private bool isAdmin; + private List allFeedback = []; + private List myFeedback = []; + + protected override Task OnAuthenticatedAsync() + { + // The feedback listing is gated to the configured admin account only. + // An unset/blank Admin:Email means nobody is admin (safe default). + var adminEmail = configuration["Admin:Email"]; + isAdmin = + !string.IsNullOrWhiteSpace(adminEmail) + && string.Equals(adminEmail, CurrentUserEmail, StringComparison.OrdinalIgnoreCase); + + // Every user sees their own submission history. + myFeedback = feedbackManagement.GetForUser(CurrentUserId); + if (isAdmin) + allFeedback = feedbackManagement.GetAll(); + + return Task.CompletedTask; + } + + private void SubmitFeedback() + { + submitError = null; + submitMessage = null; + + if (isSubmitting) + return; + isSubmitting = true; + try + { + feedbackManagement.Submit(CurrentUserId, message, category); + } + catch (ArgumentException) + { + // An empty message is the common case (length is capped client-side + // and the category comes from a fixed dropdown); map it to a clear + // localized message and fall back to a generic one for any other + // engine validation failure rather than surfacing English text. + submitError = string.IsNullOrWhiteSpace(message) + ? localizer["Feedback_EmptyError"] + : localizer["Feedback_SubmitValidationError"]; + return; + } + finally + { + isSubmitting = false; + } + + message = string.Empty; + category = Feedback.FeedbackCategory.Bug; + submitMessage = localizer["Feedback_Thanks"]; + + // Reflect the new row immediately in the user's own history (and the + // admin's full listing). + myFeedback = feedbackManagement.GetForUser(CurrentUserId); + if (isAdmin) + allFeedback = feedbackManagement.GetAll(); + + StateHasChanged(); + } + + // Admin-only: change a submission's status from the listing. + private void ChangeStatus(int feedbackId, ChangeEventArgs e) + { + if (!isAdmin) + return; + if (Enum.TryParse(e.Value?.ToString(), out var status)) + { + feedbackManagement.UpdateStatus(feedbackId, status); + allFeedback = feedbackManagement.GetAll(); + myFeedback = feedbackManagement.GetForUser(CurrentUserId); + StateHasChanged(); + } + } +} diff --git a/WSIST/WSIST.Web/Components/Pages/Home.razor b/WSIST/WSIST.Web/Components/Pages/Home.razor index 93c1356..9fc518b 100644 --- a/WSIST/WSIST.Web/Components/Pages/Home.razor +++ b/WSIST/WSIST.Web/Components/Pages/Home.razor @@ -11,30 +11,31 @@ WSIST - Settings - Study → - Logout + + @localizer["Common_Settings"] + @localizer["Common_Study"] → + @localizer["Common_Logout"]
-

Your tests

-

Upcoming exams and assessments.

+

@localizer["Home_Title"]

+

@localizer["Home_Subtitle"]

- +
@if (MissingGrades.Any()) {
- ⏳ Enter missing grades + ⏳ @localizer["Home_EnterMissingGrades"]
@foreach (var test in MissingGrades) { @@ -53,15 +54,15 @@ var days = topRecommendation.DueDate.DayNumber - DateOnly.FromDateTime(DateTime.Today).DayNumber;
-
Study today
+
@localizer["Home_StudyToday"]
@topRecommendation.Title
- @(subjects.FirstOrDefault(s => s.Id == topRecommendation.Subject)?.Name ?? "Unknown") · in @days day@(days == 1 ? "" : "s") · @score/40 pts + @(subjects.FirstOrDefault(s => s.Id == topRecommendation.Subject)?.Name ?? localizer["Common_Unknown"].Value) · @(days == 1 ? localizer["Common_InOneDay"] : localizer["Common_InDaysFmt", days]) · @score/40 @localizer["Common_Points"]
- Full plan → + @localizer["Home_FullPlan"] →
@@ -75,8 +76,8 @@ {

📋

-

No upcoming tests

-

Add your first test to get started.

+

@localizer["Home_NoUpcomingTitle"]

+

@localizer["Home_NoUpcomingSub"]

} else @@ -84,11 +85,11 @@ - - - - - + + + + + @@ -97,13 +98,13 @@ { - + - - + + } @@ -118,7 +119,7 @@ @if (subjectAverages.Any()) {
-

Grades

+

@localizer["Home_Grades"]

@foreach (var (subject, avg) in subjectAverages.OrderBy(x => x.Value)) { @@ -138,7 +139,7 @@ @if (gradeHistory.Any()) {
TitleSubjectDateVolumeUnderstanding@localizer["Home_ColTitle"]@localizer["Home_ColSubject"]@localizer["Home_ColDate"]@localizer["Home_ColVolume"]@localizer["Home_ColUnderstanding"]
@test.Title@(subjects.FirstOrDefault(s => s.Id == test.Subject)?.Name ?? "Unknown")@(subjects.FirstOrDefault(s => s.Id == test.Subject)?.Name ?? localizer["Common_Unknown"].Value) @test.DueDate.ToShortDateString()@Test.VolumeHelper(test.Volume)@Test.UnderstandingHelper(test.Understanding)@localizer[$"Level_{test.Volume}"]@localizer[$"Level_{test.Understanding}"] - - + +