diff --git a/RR3CommunityServer/Controllers/NotificationsController.cs b/RR3CommunityServer/Controllers/NotificationsController.cs new file mode 100644 index 0000000..3dd8224 --- /dev/null +++ b/RR3CommunityServer/Controllers/NotificationsController.cs @@ -0,0 +1,257 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using RR3CommunityServer.Data; +using RR3CommunityServer.Models; + +namespace RR3CommunityServer.Controllers; + +[ApiController] +[Route("synergy/notifications")] +public class NotificationsController : ControllerBase +{ + private readonly RR3DbContext _context; + private readonly ILogger _logger; + + public NotificationsController(RR3DbContext context, ILogger logger) + { + _context = context; + _logger = logger; + } + + // GET /synergy/notifications?synergyId=xxx&unreadOnly=false&limit=50 + [HttpGet] + public async Task GetNotifications( + [FromQuery] string synergyId, + [FromQuery] bool unreadOnly = false, + [FromQuery] int limit = 50) + { + try + { + if (string.IsNullOrEmpty(synergyId)) + return BadRequest(new SynergyResponse { resultCode = -1, message = "synergyId required" }); + + var user = await _context.Users.FirstOrDefaultAsync(u => u.SynergyId == synergyId); + if (user == null) + return NotFound(new SynergyResponse { resultCode = -404, message = "Player not found" }); + + var now = DateTime.UtcNow; + var query = _context.Notifications + .Where(n => n.UserId == user.Id && (n.ExpiresAt == null || n.ExpiresAt > now)); + + if (unreadOnly) + query = query.Where(n => !n.IsRead); + + var notifications = await query + .OrderByDescending(n => n.CreatedAt) + .Take(limit) + .ToListAsync(); + + var unreadCount = await _context.Notifications + .CountAsync(n => n.UserId == user.Id && !n.IsRead && (n.ExpiresAt == null || n.ExpiresAt > now)); + + var dtos = notifications.Select(n => new NotificationDto + { + Id = n.Id, + Type = n.Type, + Title = n.Title, + Message = n.Message, + IsRead = n.IsRead, + CreatedAt = new DateTimeOffset(n.CreatedAt).ToUnixTimeSeconds(), + ExpiresAt = n.ExpiresAt.HasValue ? new DateTimeOffset(n.ExpiresAt.Value).ToUnixTimeSeconds() : null + }).ToList(); + + _logger.LogInformation("Retrieved {Count} notifications for {SynergyId}", dtos.Count, synergyId); + + return Ok(new SynergyResponse + { + resultCode = 0, + data = new NotificationsResponse + { + Notifications = dtos, + TotalCount = dtos.Count, + UnreadCount = unreadCount + } + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting notifications for {SynergyId}", synergyId); + return StatusCode(500, new SynergyResponse { resultCode = -500, message = "Internal server error" }); + } + } + + // GET /synergy/notifications/unread-count?synergyId=xxx + [HttpGet("unread-count")] + public async Task GetUnreadCount([FromQuery] string synergyId) + { + try + { + if (string.IsNullOrEmpty(synergyId)) + return BadRequest(new SynergyResponse { resultCode = -1, message = "synergyId required" }); + + var user = await _context.Users.FirstOrDefaultAsync(u => u.SynergyId == synergyId); + if (user == null) + return Ok(new SynergyResponse { resultCode = 0, data = new UnreadCountResponse { UnreadCount = 0 } }); + + var now = DateTime.UtcNow; + var count = await _context.Notifications + .CountAsync(n => n.UserId == user.Id && !n.IsRead && (n.ExpiresAt == null || n.ExpiresAt > now)); + + return Ok(new SynergyResponse + { + resultCode = 0, + data = new UnreadCountResponse { UnreadCount = count } + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting unread count for {SynergyId}", synergyId); + return StatusCode(500, new SynergyResponse { resultCode = -500, message = "Internal server error" }); + } + } + + // POST /synergy/notifications/mark-read + [HttpPost("mark-read")] + public async Task MarkRead([FromBody] MarkReadRequest request) + { + try + { + if (string.IsNullOrEmpty(request.SynergyId)) + return BadRequest(new SynergyResponse { resultCode = -1, message = "synergyId required" }); + + var user = await _context.Users.FirstOrDefaultAsync(u => u.SynergyId == request.SynergyId); + if (user == null) + return NotFound(new SynergyResponse { resultCode = -404, message = "Player not found" }); + + IQueryable query = _context.Notifications.Where(n => n.UserId == user.Id && !n.IsRead); + + // If specific IDs provided, only mark those; otherwise mark all + if (request.NotificationIds != null && request.NotificationIds.Count > 0) + query = query.Where(n => request.NotificationIds.Contains(n.Id)); + + var notifications = await query.ToListAsync(); + foreach (var n in notifications) + n.IsRead = true; + + await _context.SaveChangesAsync(); + + _logger.LogInformation("Marked {Count} notifications read for {SynergyId}", notifications.Count, request.SynergyId); + + return Ok(new SynergyResponse + { + resultCode = 0, + message = $"Marked {notifications.Count} notification(s) as read" + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error marking notifications read for {SynergyId}", request.SynergyId); + return StatusCode(500, new SynergyResponse { resultCode = -500, message = "Internal server error" }); + } + } + + // POST /synergy/notifications/send (admin/system use) + [HttpPost("send")] + public async Task SendNotification([FromBody] SendNotificationRequest request) + { + try + { + if (string.IsNullOrEmpty(request.Title) || string.IsNullOrEmpty(request.Message)) + return BadRequest(new SynergyResponse { resultCode = -1, message = "title and message required" }); + + DateTime? expiresAt = request.ExpiresInHours.HasValue + ? DateTime.UtcNow.AddHours(request.ExpiresInHours.Value) + : null; + + int sentCount = 0; + + if (string.IsNullOrEmpty(request.SynergyId)) + { + // Broadcast to all users + var allUsers = await _context.Users.Select(u => u.Id).ToListAsync(); + var bulk = allUsers.Select(uid => new Notification + { + UserId = uid, + Type = request.Type, + Title = request.Title, + Message = request.Message, + CreatedAt = DateTime.UtcNow, + ExpiresAt = expiresAt + }).ToList(); + + _context.Notifications.AddRange(bulk); + sentCount = bulk.Count; + } + else + { + // Send to specific player + var user = await _context.Users.FirstOrDefaultAsync(u => u.SynergyId == request.SynergyId); + if (user == null) + return NotFound(new SynergyResponse { resultCode = -404, message = "Player not found" }); + + _context.Notifications.Add(new Notification + { + UserId = user.Id, + Type = request.Type, + Title = request.Title, + Message = request.Message, + CreatedAt = DateTime.UtcNow, + ExpiresAt = expiresAt + }); + sentCount = 1; + } + + await _context.SaveChangesAsync(); + + _logger.LogInformation("Sent notification '{Title}' to {Count} player(s)", request.Title, sentCount); + + return Ok(new SynergyResponse + { + resultCode = 0, + message = $"Notification sent to {sentCount} player(s)" + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error sending notification"); + return StatusCode(500, new SynergyResponse { resultCode = -500, message = "Internal server error" }); + } + } + + // DELETE /synergy/notifications/{id}?synergyId=xxx + [HttpDelete("{id:int}")] + public async Task DeleteNotification(int id, [FromQuery] string synergyId) + { + try + { + if (string.IsNullOrEmpty(synergyId)) + return BadRequest(new SynergyResponse { resultCode = -1, message = "synergyId required" }); + + var user = await _context.Users.FirstOrDefaultAsync(u => u.SynergyId == synergyId); + if (user == null) + return NotFound(new SynergyResponse { resultCode = -404, message = "Player not found" }); + + var notification = await _context.Notifications + .FirstOrDefaultAsync(n => n.Id == id && n.UserId == user.Id); + + if (notification == null) + return NotFound(new SynergyResponse { resultCode = -404, message = "Notification not found" }); + + _context.Notifications.Remove(notification); + await _context.SaveChangesAsync(); + + _logger.LogInformation("Deleted notification {Id} for {SynergyId}", id, synergyId); + + return Ok(new SynergyResponse + { + resultCode = 0, + message = "Notification deleted" + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting notification {Id}", id); + return StatusCode(500, new SynergyResponse { resultCode = -500, message = "Internal server error" }); + } + } +} diff --git a/RR3CommunityServer/Data/RR3DbContext.cs b/RR3CommunityServer/Data/RR3DbContext.cs index f1b2237..2f2486a 100644 --- a/RR3CommunityServer/Data/RR3DbContext.cs +++ b/RR3CommunityServer/Data/RR3DbContext.cs @@ -30,6 +30,7 @@ public class RR3DbContext : DbContext public DbSet Events { get; set; } public DbSet EventCompletions { get; set; } public DbSet EventAttempts { get; set; } + public DbSet Notifications { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -534,3 +535,19 @@ public class EventAttempt public User? User { get; set; } public Event? Event { get; set; } } + +// In-game notifications +public class Notification +{ + public int Id { get; set; } + public int UserId { get; set; } + public string Type { get; set; } = string.Empty; // "reward", "event", "system", "friend" + public string Title { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; + public bool IsRead { get; set; } = false; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime? ExpiresAt { get; set; } + + // Navigation property + public User? User { get; set; } +} diff --git a/RR3CommunityServer/Migrations/20260224000752_AddNotificationsTable.Designer.cs b/RR3CommunityServer/Migrations/20260224000752_AddNotificationsTable.Designer.cs new file mode 100644 index 0000000..0c26f35 --- /dev/null +++ b/RR3CommunityServer/Migrations/20260224000752_AddNotificationsTable.Designer.cs @@ -0,0 +1,1313 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using RR3CommunityServer.Data; + +#nullable disable + +namespace RR3CommunityServer.Migrations +{ + [DbContext(typeof(RR3DbContext))] + [Migration("20260224000752_AddNotificationsTable")] + partial class AddNotificationsTable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.11"); + + modelBuilder.Entity("RR3CommunityServer.Data.Car", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Available") + .HasColumnType("INTEGER"); + + b.Property("BasePerformanceRating") + .HasColumnType("INTEGER"); + + b.Property("CarId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CashPrice") + .HasColumnType("INTEGER"); + + b.Property("ClassType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CustomAuthor") + .HasColumnType("TEXT"); + + b.Property("CustomVersion") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("GoldPrice") + .HasColumnType("INTEGER"); + + b.Property("IsCustom") + .HasColumnType("INTEGER"); + + b.Property("Manufacturer") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Cars"); + + b.HasData( + new + { + Id = 1, + Available = true, + BasePerformanceRating = 45, + CarId = "nissan_silvia_s15", + CashPrice = 25000, + ClassType = "C", + GoldPrice = 0, + IsCustom = false, + Manufacturer = "Nissan", + Name = "Nissan Silvia Spec-R", + Year = 0 + }, + new + { + Id = 2, + Available = true, + BasePerformanceRating = 58, + CarId = "ford_focus_rs", + CashPrice = 85000, + ClassType = "B", + GoldPrice = 150, + IsCustom = false, + Manufacturer = "Ford", + Name = "Ford Focus RS", + Year = 0 + }, + new + { + Id = 3, + Available = true, + BasePerformanceRating = 72, + CarId = "porsche_911_gt3", + CashPrice = 0, + ClassType = "A", + GoldPrice = 350, + IsCustom = false, + Manufacturer = "Porsche", + Name = "Porsche 911 GT3 RS", + Year = 0 + }, + new + { + Id = 4, + Available = true, + BasePerformanceRating = 88, + CarId = "ferrari_488_gtb", + CashPrice = 0, + ClassType = "S", + GoldPrice = 750, + IsCustom = false, + Manufacturer = "Ferrari", + Name = "Ferrari 488 GTB", + Year = 0 + }, + new + { + Id = 5, + Available = true, + BasePerformanceRating = 105, + CarId = "mclaren_p1_gtr", + CashPrice = 0, + ClassType = "R", + GoldPrice = 1500, + IsCustom = false, + Manufacturer = "McLaren", + Name = "McLaren P1 GTR", + Year = 0 + }); + }); + + modelBuilder.Entity("RR3CommunityServer.Data.CarUpgrade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CarId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CashCost") + .HasColumnType("INTEGER"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("PerformanceIncrease") + .HasColumnType("INTEGER"); + + b.Property("UpgradeType") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("CarUpgrades"); + + b.HasData( + new + { + Id = 1, + CarId = "nissan_silvia_s15", + CashCost = 5000, + Level = 1, + PerformanceIncrease = 3, + UpgradeType = "engine" + }, + new + { + Id = 2, + CarId = "nissan_silvia_s15", + CashCost = 3000, + Level = 1, + PerformanceIncrease = 2, + UpgradeType = "tires" + }, + new + { + Id = 3, + CarId = "nissan_silvia_s15", + CashCost = 4000, + Level = 1, + PerformanceIncrease = 2, + UpgradeType = "suspension" + }, + new + { + Id = 4, + CarId = "nissan_silvia_s15", + CashCost = 3500, + Level = 1, + PerformanceIncrease = 2, + UpgradeType = "brakes" + }, + new + { + Id = 5, + CarId = "nissan_silvia_s15", + CashCost = 4500, + Level = 1, + PerformanceIncrease = 3, + UpgradeType = "drivetrain" + }); + }); + + modelBuilder.Entity("RR3CommunityServer.Data.CareerProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BestTime") + .HasColumnType("REAL"); + + b.Property("Completed") + .HasColumnType("INTEGER"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("EventName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SeriesName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StarsEarned") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("CareerProgress"); + }); + + modelBuilder.Entity("RR3CommunityServer.Data.CatalogItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Available") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("TEXT"); + + b.Property("Sku") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("CatalogItems"); + + b.HasData( + new + { + Id = 1, + Available = true, + Name = "1000 Gold", + Price = 0.99m, + Sku = "com.ea.rr3.gold_1000", + Type = "currency" + }, + new + { + Id = 2, + Available = true, + Name = "Starter Car", + Price = 0m, + Sku = "com.ea.rr3.car_tier1", + Type = "car" + }, + new + { + Id = 3, + Available = true, + Name = "Engine Upgrade", + Price = 4.99m, + Sku = "com.ea.rr3.upgrade_engine", + Type = "upgrade" + }, + new + { + Id = 4, + Available = true, + Name = "100 Gold", + Price = 0m, + Sku = "com.ea.rr3.gold_100", + Type = "currency" + }, + new + { + Id = 5, + Available = true, + Name = "500 Gold", + Price = 0m, + Sku = "com.ea.rr3.gold_500", + Type = "currency" + }, + new + { + Id = 6, + Available = true, + Name = "1000 Gold", + Price = 0m, + Sku = "com.ea.rr3.gold_1000", + Type = "currency" + }, + new + { + Id = 7, + Available = true, + Name = "5000 Gold", + Price = 0m, + Sku = "com.ea.rr3.gold_5000", + Type = "currency" + }); + }); + + modelBuilder.Entity("RR3CommunityServer.Data.DailyReward", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CashAmount") + .HasColumnType("INTEGER"); + + b.Property("Claimed") + .HasColumnType("INTEGER"); + + b.Property("ClaimedAt") + .HasColumnType("TEXT"); + + b.Property("GoldAmount") + .HasColumnType("INTEGER"); + + b.Property("RewardDate") + .HasColumnType("TEXT"); + + b.Property("Streak") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("DailyRewards"); + }); + + modelBuilder.Entity("RR3CommunityServer.Data.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("HardwareId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Devices"); + }); + + modelBuilder.Entity("RR3CommunityServer.Data.Event", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Active") + .HasColumnType("INTEGER"); + + b.Property("CashReward") + .HasColumnType("INTEGER"); + + b.Property("EventCode") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("EventOrder") + .HasColumnType("INTEGER"); + + b.Property("EventType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("GoldReward") + .HasColumnType("INTEGER"); + + b.Property("Laps") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RequiredCarClass") + .HasColumnType("TEXT"); + + b.Property("RequiredPR") + .HasColumnType("INTEGER"); + + b.Property("SeriesName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SeriesOrder") + .HasColumnType("INTEGER"); + + b.Property("TargetTime") + .HasColumnType("REAL"); + + b.Property("Track") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("XPReward") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Events"); + }); + + modelBuilder.Entity("RR3CommunityServer.Data.EventAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Completed") + .HasColumnType("INTEGER"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("EventId") + .HasColumnType("INTEGER"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("TimeSeconds") + .HasColumnType("REAL"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EventId"); + + b.HasIndex("UserId"); + + b.ToTable("EventAttempts"); + }); + + modelBuilder.Entity("RR3CommunityServer.Data.EventCompletion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BestTime") + .HasColumnType("REAL"); + + b.Property("CompletionCount") + .HasColumnType("INTEGER"); + + b.Property("EventId") + .HasColumnType("INTEGER"); + + b.Property("FirstCompletedAt") + .HasColumnType("TEXT"); + + b.Property("LastCompletedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EventId"); + + b.HasIndex("UserId"); + + b.ToTable("EventCompletions"); + }); + + modelBuilder.Entity("RR3CommunityServer.Data.GameAsset", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessCount") + .HasColumnType("INTEGER"); + + b.Property("AssetId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AssetType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CarId") + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CompressedSize") + .HasColumnType("INTEGER"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CustomAuthor") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("DownloadedAt") + .HasColumnType("TEXT"); + + b.Property("EaCdnPath") + .HasColumnType("TEXT"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FileSha256") + .HasColumnType("TEXT"); + + b.Property("FileSize") + .HasColumnType("INTEGER"); + + b.Property("IsAvailable") + .HasColumnType("INTEGER"); + + b.Property("IsCustomContent") + .HasColumnType("INTEGER"); + + b.Property("IsRequired") + .HasColumnType("INTEGER"); + + b.Property("LastAccessedAt") + .HasColumnType("TEXT"); + + b.Property("LocalPath") + .HasColumnType("TEXT"); + + b.Property("Md5Hash") + .HasColumnType("TEXT"); + + b.Property("OriginalUrl") + .HasColumnType("TEXT"); + + b.Property("TrackId") + .HasColumnType("TEXT"); + + b.Property("UploadedAt") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("GameAssets"); + }); + + modelBuilder.Entity("RR3CommunityServer.Data.LeaderboardEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CarName") + .HasColumnType("TEXT"); + + b.Property("PlayerName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RecordCategory") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RecordType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SubmittedAt") + .HasColumnType("TEXT"); + + b.Property("SynergyId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TimeSeconds") + .HasColumnType("REAL"); + + b.Property("TrackName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("LeaderboardEntries"); + }); + + modelBuilder.Entity("RR3CommunityServer.Data.ModPack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CarIds") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("DownloadCount") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PackId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("TrackIds") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Version") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ModPacks"); + }); + + modelBuilder.Entity("RR3CommunityServer.Data.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("IsRead") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("RR3CommunityServer.Data.OwnedCar", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CarId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CarName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ClassType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Manufacturer") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PerformanceRating") + .HasColumnType("INTEGER"); + + b.Property("PurchasedAt") + .HasColumnType("TEXT"); + + b.Property("PurchasedUpgrades") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpgradeLevel") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("OwnedCars"); + }); + + modelBuilder.Entity("RR3CommunityServer.Data.PersonalRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AchievedAt") + .HasColumnType("TEXT"); + + b.Property("BestTimeSeconds") + .HasColumnType("REAL"); + + b.Property("CarName") + .HasColumnType("TEXT"); + + b.Property("ImprovementSeconds") + .HasColumnType("REAL"); + + b.Property("PreviousBestTime") + .HasColumnType("TEXT"); + + b.Property("RecordCategory") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RecordType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SynergyId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TotalAttempts") + .HasColumnType("INTEGER"); + + b.Property("TrackName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("PersonalRecords"); + }); + + modelBuilder.Entity("RR3CommunityServer.Data.PlayerSave", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("SaveDataJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SynergyId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("PlayerSaves"); + }); + + modelBuilder.Entity("RR3CommunityServer.Data.Purchase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OrderId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Price") + .HasColumnType("TEXT"); + + b.Property("PurchaseTime") + .HasColumnType("TEXT"); + + b.Property("Sku") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SynergyId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Token") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Purchases"); + }); + + modelBuilder.Entity("RR3CommunityServer.Data.Session", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("SessionId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SynergyId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Sessions"); + }); + + modelBuilder.Entity("RR3CommunityServer.Data.TimeTrial", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Active") + .HasColumnType("INTEGER"); + + b.Property("CarName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CashReward") + .HasColumnType("INTEGER"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("GoldReward") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("TargetTime") + .HasColumnType("REAL"); + + b.Property("TrackName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("TimeTrials"); + + b.HasData( + new + { + Id = 1, + Active = true, + CarName = "Any Car", + CashReward = 10000, + EndDate = new DateTime(2026, 3, 3, 0, 7, 51, 537, DateTimeKind.Utc).AddTicks(6445), + GoldReward = 50, + Name = "Daily Sprint Challenge", + StartDate = new DateTime(2026, 2, 24, 0, 7, 51, 537, DateTimeKind.Utc).AddTicks(6442), + TargetTime = 90.5, + TrackName = "Silverstone National" + }, + new + { + Id = 2, + Active = true, + CarName = "Any Car", + CashReward = 25000, + EndDate = new DateTime(2026, 3, 3, 0, 7, 51, 537, DateTimeKind.Utc).AddTicks(6454), + GoldReward = 100, + Name = "Speed Demon Trial", + StartDate = new DateTime(2026, 2, 24, 0, 7, 51, 537, DateTimeKind.Utc).AddTicks(6453), + TargetTime = 120.0, + TrackName = "Dubai Autodrome" + }); + }); + + modelBuilder.Entity("RR3CommunityServer.Data.TimeTrialResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BeatTarget") + .HasColumnType("INTEGER"); + + b.Property("CashEarned") + .HasColumnType("INTEGER"); + + b.Property("GoldEarned") + .HasColumnType("INTEGER"); + + b.Property("SubmittedAt") + .HasColumnType("TEXT"); + + b.Property("TimeSeconds") + .HasColumnType("REAL"); + + b.Property("TimeTrialId") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TimeTrialId"); + + b.HasIndex("UserId"); + + b.ToTable("TimeTrialResults"); + }); + + modelBuilder.Entity("RR3CommunityServer.Data.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Cash") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .HasColumnType("TEXT"); + + b.Property("Experience") + .HasColumnType("INTEGER"); + + b.Property("Gold") + .HasColumnType("INTEGER"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("Nickname") + .HasColumnType("TEXT"); + + b.Property("Reputation") + .HasColumnType("INTEGER"); + + b.Property("SynergyId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("RR3CommunityServer.Models.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("EmailVerificationToken") + .HasColumnType("TEXT"); + + b.Property("EmailVerified") + .HasColumnType("INTEGER"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LastLoginAt") + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PasswordResetExpiry") + .HasColumnType("TEXT"); + + b.Property("PasswordResetToken") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("RR3CommunityServer.Models.DeviceAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccountId") + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .HasColumnType("TEXT"); + + b.Property("LastUsedAt") + .HasColumnType("TEXT"); + + b.Property("LinkedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("DeviceAccounts"); + }); + + modelBuilder.Entity("RR3CommunityServer.Models.UserSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastUpdated") + .HasColumnType("TEXT"); + + b.Property("Mode") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ServerUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("UserSettings"); + }); + + modelBuilder.Entity("RR3CommunityServer.Data.CareerProgress", b => + { + b.HasOne("RR3CommunityServer.Data.User", null) + .WithMany("CareerProgress") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RR3CommunityServer.Data.EventAttempt", b => + { + b.HasOne("RR3CommunityServer.Data.Event", "Event") + .WithMany() + .HasForeignKey("EventId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("RR3CommunityServer.Data.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Event"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("RR3CommunityServer.Data.EventCompletion", b => + { + b.HasOne("RR3CommunityServer.Data.Event", "Event") + .WithMany() + .HasForeignKey("EventId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("RR3CommunityServer.Data.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Event"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("RR3CommunityServer.Data.Notification", b => + { + b.HasOne("RR3CommunityServer.Data.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("RR3CommunityServer.Data.OwnedCar", b => + { + b.HasOne("RR3CommunityServer.Data.User", null) + .WithMany("OwnedCars") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RR3CommunityServer.Data.TimeTrialResult", b => + { + b.HasOne("RR3CommunityServer.Data.TimeTrial", "TimeTrial") + .WithMany() + .HasForeignKey("TimeTrialId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("RR3CommunityServer.Data.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TimeTrial"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("RR3CommunityServer.Models.Account", b => + { + b.HasOne("RR3CommunityServer.Data.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("RR3CommunityServer.Models.DeviceAccount", b => + { + b.HasOne("RR3CommunityServer.Models.Account", "Account") + .WithMany("LinkedDevices") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("RR3CommunityServer.Data.User", b => + { + b.Navigation("CareerProgress"); + + b.Navigation("OwnedCars"); + }); + + modelBuilder.Entity("RR3CommunityServer.Models.Account", b => + { + b.Navigation("LinkedDevices"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/RR3CommunityServer/Migrations/20260224000752_AddNotificationsTable.cs b/RR3CommunityServer/Migrations/20260224000752_AddNotificationsTable.cs new file mode 100644 index 0000000..71ea909 --- /dev/null +++ b/RR3CommunityServer/Migrations/20260224000752_AddNotificationsTable.cs @@ -0,0 +1,80 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RR3CommunityServer.Migrations +{ + /// + public partial class AddNotificationsTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Notifications", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + UserId = table.Column(type: "INTEGER", nullable: false), + Type = table.Column(type: "TEXT", nullable: false), + Title = table.Column(type: "TEXT", nullable: false), + Message = table.Column(type: "TEXT", nullable: false), + IsRead = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + ExpiresAt = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Notifications", x => x.Id); + table.ForeignKey( + name: "FK_Notifications_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.UpdateData( + table: "TimeTrials", + keyColumn: "Id", + keyValue: 1, + columns: new[] { "EndDate", "StartDate" }, + values: new object[] { new DateTime(2026, 3, 3, 0, 7, 51, 537, DateTimeKind.Utc).AddTicks(6445), new DateTime(2026, 2, 24, 0, 7, 51, 537, DateTimeKind.Utc).AddTicks(6442) }); + + migrationBuilder.UpdateData( + table: "TimeTrials", + keyColumn: "Id", + keyValue: 2, + columns: new[] { "EndDate", "StartDate" }, + values: new object[] { new DateTime(2026, 3, 3, 0, 7, 51, 537, DateTimeKind.Utc).AddTicks(6454), new DateTime(2026, 2, 24, 0, 7, 51, 537, DateTimeKind.Utc).AddTicks(6453) }); + + migrationBuilder.CreateIndex( + name: "IX_Notifications_UserId", + table: "Notifications", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Notifications"); + + migrationBuilder.UpdateData( + table: "TimeTrials", + keyColumn: "Id", + keyValue: 1, + columns: new[] { "EndDate", "StartDate" }, + values: new object[] { new DateTime(2026, 3, 2, 1, 55, 50, 958, DateTimeKind.Utc).AddTicks(35), new DateTime(2026, 2, 23, 1, 55, 50, 958, DateTimeKind.Utc).AddTicks(32) }); + + migrationBuilder.UpdateData( + table: "TimeTrials", + keyColumn: "Id", + keyValue: 2, + columns: new[] { "EndDate", "StartDate" }, + values: new object[] { new DateTime(2026, 3, 2, 1, 55, 50, 958, DateTimeKind.Utc).AddTicks(43), new DateTime(2026, 2, 23, 1, 55, 50, 958, DateTimeKind.Utc).AddTicks(43) }); + } + } +} diff --git a/RR3CommunityServer/Migrations/RR3DbContextModelSnapshot.cs b/RR3CommunityServer/Migrations/RR3DbContextModelSnapshot.cs index b9320ba..3f0bd66 100644 --- a/RR3CommunityServer/Migrations/RR3DbContextModelSnapshot.cs +++ b/RR3CommunityServer/Migrations/RR3DbContextModelSnapshot.cs @@ -710,6 +710,43 @@ namespace RR3CommunityServer.Migrations b.ToTable("ModPacks"); }); + modelBuilder.Entity("RR3CommunityServer.Data.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("IsRead") + .HasColumnType("INTEGER"); + + b.Property("Message") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Notifications"); + }); + modelBuilder.Entity("RR3CommunityServer.Data.OwnedCar", b => { b.Property("Id") @@ -949,10 +986,10 @@ namespace RR3CommunityServer.Migrations Active = true, CarName = "Any Car", CashReward = 10000, - EndDate = new DateTime(2026, 3, 2, 1, 55, 50, 958, DateTimeKind.Utc).AddTicks(35), + EndDate = new DateTime(2026, 3, 3, 0, 7, 51, 537, DateTimeKind.Utc).AddTicks(6445), GoldReward = 50, Name = "Daily Sprint Challenge", - StartDate = new DateTime(2026, 2, 23, 1, 55, 50, 958, DateTimeKind.Utc).AddTicks(32), + StartDate = new DateTime(2026, 2, 24, 0, 7, 51, 537, DateTimeKind.Utc).AddTicks(6442), TargetTime = 90.5, TrackName = "Silverstone National" }, @@ -962,10 +999,10 @@ namespace RR3CommunityServer.Migrations Active = true, CarName = "Any Car", CashReward = 25000, - EndDate = new DateTime(2026, 3, 2, 1, 55, 50, 958, DateTimeKind.Utc).AddTicks(43), + EndDate = new DateTime(2026, 3, 3, 0, 7, 51, 537, DateTimeKind.Utc).AddTicks(6454), GoldReward = 100, Name = "Speed Demon Trial", - StartDate = new DateTime(2026, 2, 23, 1, 55, 50, 958, DateTimeKind.Utc).AddTicks(43), + StartDate = new DateTime(2026, 2, 24, 0, 7, 51, 537, DateTimeKind.Utc).AddTicks(6453), TargetTime = 120.0, TrackName = "Dubai Autodrome" }); @@ -1197,6 +1234,17 @@ namespace RR3CommunityServer.Migrations b.Navigation("User"); }); + modelBuilder.Entity("RR3CommunityServer.Data.Notification", b => + { + b.HasOne("RR3CommunityServer.Data.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("RR3CommunityServer.Data.OwnedCar", b => { b.HasOne("RR3CommunityServer.Data.User", null) diff --git a/RR3CommunityServer/Models/ApiModels.cs b/RR3CommunityServer/Models/ApiModels.cs index 9ed85d0..b2966b0 100644 --- a/RR3CommunityServer/Models/ApiModels.cs +++ b/RR3CommunityServer/Models/ApiModels.cs @@ -328,3 +328,43 @@ public class RecordSubmissionResponse public int GoldEarned { get; set; } public int CashEarned { get; set; } } + +// ==================== NOTIFICATIONS ==================== + +public class NotificationDto +{ + public int Id { get; set; } + public string Type { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; + public bool IsRead { get; set; } + public long CreatedAt { get; set; } // Unix timestamp + public long? ExpiresAt { get; set; } +} + +public class NotificationsResponse +{ + public List Notifications { get; set; } = new(); + public int TotalCount { get; set; } + public int UnreadCount { get; set; } +} + +public class UnreadCountResponse +{ + public int UnreadCount { get; set; } +} + +public class MarkReadRequest +{ + public string SynergyId { get; set; } = string.Empty; + public List? NotificationIds { get; set; } // null = mark all read +} + +public class SendNotificationRequest +{ + public string? SynergyId { get; set; } // null = send to all players + public string Type { get; set; } = "system"; + public string Title { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; + public int? ExpiresInHours { get; set; } // null = never expires +}