Add Notifications Service - 5 endpoints, 75/73+ complete
- NotificationsController.cs with 5 endpoints:
* GET /synergy/notifications - list with unreadOnly filter
* GET /synergy/notifications/unread-count - badge count
* POST /synergy/notifications/mark-read - single or bulk
* POST /synergy/notifications/send - player or broadcast
* DELETE /synergy/notifications/{id} - delete by player
- Notification entity added to RR3DbContext (FK to Users, cascade delete)
- NotificationDto, NotificationsResponse, MarkReadRequest,
SendNotificationRequest models added to ApiModels.cs
- Migration AddNotificationsTable applied
- Build: 0 errors, 0 warnings
- All 5 endpoints tested and working
Server now: 75/73 core endpoints (notifications + admin delete = 76)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
257
RR3CommunityServer/Controllers/NotificationsController.cs
Normal file
257
RR3CommunityServer/Controllers/NotificationsController.cs
Normal file
@@ -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<NotificationsController> _logger;
|
||||
|
||||
public NotificationsController(RR3DbContext context, ILogger<NotificationsController> logger)
|
||||
{
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// GET /synergy/notifications?synergyId=xxx&unreadOnly=false&limit=50
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetNotifications(
|
||||
[FromQuery] string synergyId,
|
||||
[FromQuery] bool unreadOnly = false,
|
||||
[FromQuery] int limit = 50)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(synergyId))
|
||||
return BadRequest(new SynergyResponse<object> { resultCode = -1, message = "synergyId required" });
|
||||
|
||||
var user = await _context.Users.FirstOrDefaultAsync(u => u.SynergyId == synergyId);
|
||||
if (user == null)
|
||||
return NotFound(new SynergyResponse<object> { 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<NotificationsResponse>
|
||||
{
|
||||
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<object> { resultCode = -500, message = "Internal server error" });
|
||||
}
|
||||
}
|
||||
|
||||
// GET /synergy/notifications/unread-count?synergyId=xxx
|
||||
[HttpGet("unread-count")]
|
||||
public async Task<IActionResult> GetUnreadCount([FromQuery] string synergyId)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(synergyId))
|
||||
return BadRequest(new SynergyResponse<object> { resultCode = -1, message = "synergyId required" });
|
||||
|
||||
var user = await _context.Users.FirstOrDefaultAsync(u => u.SynergyId == synergyId);
|
||||
if (user == null)
|
||||
return Ok(new SynergyResponse<UnreadCountResponse> { 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<UnreadCountResponse>
|
||||
{
|
||||
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<object> { resultCode = -500, message = "Internal server error" });
|
||||
}
|
||||
}
|
||||
|
||||
// POST /synergy/notifications/mark-read
|
||||
[HttpPost("mark-read")]
|
||||
public async Task<IActionResult> MarkRead([FromBody] MarkReadRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(request.SynergyId))
|
||||
return BadRequest(new SynergyResponse<object> { resultCode = -1, message = "synergyId required" });
|
||||
|
||||
var user = await _context.Users.FirstOrDefaultAsync(u => u.SynergyId == request.SynergyId);
|
||||
if (user == null)
|
||||
return NotFound(new SynergyResponse<object> { resultCode = -404, message = "Player not found" });
|
||||
|
||||
IQueryable<Notification> 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<object>
|
||||
{
|
||||
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<object> { resultCode = -500, message = "Internal server error" });
|
||||
}
|
||||
}
|
||||
|
||||
// POST /synergy/notifications/send (admin/system use)
|
||||
[HttpPost("send")]
|
||||
public async Task<IActionResult> SendNotification([FromBody] SendNotificationRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(request.Title) || string.IsNullOrEmpty(request.Message))
|
||||
return BadRequest(new SynergyResponse<object> { 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<object> { 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<object>
|
||||
{
|
||||
resultCode = 0,
|
||||
message = $"Notification sent to {sentCount} player(s)"
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error sending notification");
|
||||
return StatusCode(500, new SynergyResponse<object> { resultCode = -500, message = "Internal server error" });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /synergy/notifications/{id}?synergyId=xxx
|
||||
[HttpDelete("{id:int}")]
|
||||
public async Task<IActionResult> DeleteNotification(int id, [FromQuery] string synergyId)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(synergyId))
|
||||
return BadRequest(new SynergyResponse<object> { resultCode = -1, message = "synergyId required" });
|
||||
|
||||
var user = await _context.Users.FirstOrDefaultAsync(u => u.SynergyId == synergyId);
|
||||
if (user == null)
|
||||
return NotFound(new SynergyResponse<object> { 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<object> { 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<object>
|
||||
{
|
||||
resultCode = 0,
|
||||
message = "Notification deleted"
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error deleting notification {Id}", id);
|
||||
return StatusCode(500, new SynergyResponse<object> { resultCode = -500, message = "Internal server error" });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ public class RR3DbContext : DbContext
|
||||
public DbSet<Event> Events { get; set; }
|
||||
public DbSet<EventCompletion> EventCompletions { get; set; }
|
||||
public DbSet<EventAttempt> EventAttempts { get; set; }
|
||||
public DbSet<Notification> 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; }
|
||||
}
|
||||
|
||||
1313
RR3CommunityServer/Migrations/20260224000752_AddNotificationsTable.Designer.cs
generated
Normal file
1313
RR3CommunityServer/Migrations/20260224000752_AddNotificationsTable.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,80 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace RR3CommunityServer.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddNotificationsTable : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Notifications",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
UserId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Type = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Title = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Message = table.Column<string>(type: "TEXT", nullable: false),
|
||||
IsRead = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
ExpiresAt = table.Column<DateTime>(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");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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) });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -710,6 +710,43 @@ namespace RR3CommunityServer.Migrations
|
||||
b.ToTable("ModPacks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("RR3CommunityServer.Data.Notification", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("ExpiresAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsRead")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Notifications");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("RR3CommunityServer.Data.OwnedCar", b =>
|
||||
{
|
||||
b.Property<int>("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)
|
||||
|
||||
@@ -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<NotificationDto> 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<int>? 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user