From 4438bc5a930bba6f9a7d0ff9cc83157beac0a0fd Mon Sep 17 00:00:00 2001 From: Denis Urs Rudolph Date: Fri, 3 Apr 2026 21:12:47 +0200 Subject: [PATCH] Add Announcements and Dashboard controllers with all DTOs --- .../Controllers/AnnouncementsController.cs | 252 ++++++++++++++++++ backend/Controllers/DashboardController.cs | 167 ++++++++++++ backend/DTOs/AnnouncementDtos.cs | 42 +++ backend/DTOs/DashboardDtos.cs | 66 +++++ openspec/changes/new-raceplanner-app/tasks.md | 40 +-- 5 files changed, 547 insertions(+), 20 deletions(-) create mode 100644 backend/Controllers/AnnouncementsController.cs create mode 100644 backend/Controllers/DashboardController.cs create mode 100644 backend/DTOs/AnnouncementDtos.cs create mode 100644 backend/DTOs/DashboardDtos.cs diff --git a/backend/Controllers/AnnouncementsController.cs b/backend/Controllers/AnnouncementsController.cs new file mode 100644 index 0000000..5658f16 --- /dev/null +++ b/backend/Controllers/AnnouncementsController.cs @@ -0,0 +1,252 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using RacePlannerApi.Data; +using RacePlannerApi.DTOs; +using RacePlannerApi.Models; + +namespace RacePlannerApi.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Authorize] +public class AnnouncementsController : ControllerBase +{ + private readonly RacePlannerDbContext _context; + + public AnnouncementsController(RacePlannerDbContext context) + { + _context = context; + } + + [HttpPost] + [Authorize(Roles = "Organizer")] + public async Task> CreateAnnouncement(CreateAnnouncementRequest request) + { + var userId = GetCurrentUserId(); + if (userId == null) + { + return Unauthorized(); + } + + // Verify event exists and user is organizer + var eventEntity = await _context.Events + .FirstOrDefaultAsync(e => e.Id == request.EventId); + + if (eventEntity == null) + { + return NotFound(new { error = "Event not found" }); + } + + if (eventEntity.OrganizerId != userId.Value) + { + return Forbid(); + } + + var announcement = new Announcement + { + EventId = request.EventId, + Title = request.Title, + Content = request.Content, + AuthorId = userId.Value, + IsPublished = true + }; + + _context.Announcements.Add(announcement); + await _context.SaveChangesAsync(); + + // Load related data + await _context.Entry(announcement) + .Reference(a => a.Event) + .LoadAsync(); + await _context.Entry(announcement) + .Reference(a => a.Author) + .LoadAsync(); + + // TODO: Send notifications to registered participants (Task 7.7) + + return CreatedAtAction( + nameof(GetAnnouncement), + new { id = announcement.Id }, + MapToAnnouncementDto(announcement)); + } + + [HttpGet("{id}")] + public async Task> GetAnnouncement(Guid id) + { + var announcement = await _context.Announcements + .Include(a => a.Event) + .Include(a => a.Author) + .FirstOrDefaultAsync(a => a.Id == id); + + if (announcement == null) + { + return NotFound(); + } + + // Only show published announcements to participants + if (!announcement.IsPublished) + { + var userId = GetCurrentUserId(); + if (userId == null || announcement.Event.OrganizerId != userId.Value) + { + return NotFound(); + } + } + + return Ok(MapToAnnouncementDto(announcement)); + } + + [HttpGet("event/{eventId}")] + public async Task>> GetEventAnnouncements(Guid eventId) + { + var userId = GetCurrentUserId(); + var isOrganizer = false; + + if (userId != null) + { + isOrganizer = await _context.Events + .AnyAsync(e => e.Id == eventId && e.OrganizerId == userId.Value); + } + + var query = _context.Announcements + .Include(a => a.Event) + .Include(a => a.Author) + .Where(a => a.EventId == eventId) + .AsQueryable(); + + // Only show published announcements to non-organizers + if (!isOrganizer) + { + query = query.Where(a => a.IsPublished); + } + + var announcements = await query + .OrderByDescending(a => a.CreatedAt) + .ToListAsync(); + + return Ok(announcements.Select(MapToAnnouncementDto)); + } + + [HttpPut("{id}")] + [Authorize(Roles = "Organizer")] + public async Task> UpdateAnnouncement(Guid id, UpdateAnnouncementRequest request) + { + var userId = GetCurrentUserId(); + if (userId == null) + { + return Unauthorized(); + } + + var announcement = await _context.Announcements + .Include(a => a.Event) + .Include(a => a.Author) + .FirstOrDefaultAsync(a => a.Id == id); + + if (announcement == null) + { + return NotFound(); + } + + // Only organizer can update + if (announcement.Event.OrganizerId != userId.Value) + { + return Forbid(); + } + + // Update fields + if (request.Title != null) announcement.Title = request.Title; + if (request.Content != null) announcement.Content = request.Content; + if (request.IsPublished.HasValue) announcement.IsPublished = request.IsPublished.Value; + + announcement.UpdatedAt = DateTime.UtcNow; + + await _context.SaveChangesAsync(); + + return Ok(MapToAnnouncementDto(announcement)); + } + + [HttpDelete("{id}")] + [Authorize(Roles = "Organizer")] + public async Task DeleteAnnouncement(Guid id) + { + var userId = GetCurrentUserId(); + if (userId == null) + { + return Unauthorized(); + } + + var announcement = await _context.Announcements + .Include(a => a.Event) + .FirstOrDefaultAsync(a => a.Id == id); + + if (announcement == null) + { + return NotFound(); + } + + // Only organizer can delete + if (announcement.Event.OrganizerId != userId.Value) + { + return Forbid(); + } + + _context.Announcements.Remove(announcement); + await _context.SaveChangesAsync(); + + return NoContent(); + } + + [HttpGet("my-announcements")] + public async Task>> GetMyAnnouncements() + { + var userId = GetCurrentUserId(); + if (userId == null) + { + return Unauthorized(); + } + + // Get user registrations + var userRegistrationEventIds = await _context.Registrations + .Where(r => r.ParticipantId == userId.Value && r.Status != RegistrationStatus.Cancelled) + .Select(r => r.EventId) + .ToListAsync(); + + // Get announcements for those events + var announcements = await _context.Announcements + .Include(a => a.Event) + .Include(a => a.Author) + .Where(a => userRegistrationEventIds.Contains(a.EventId) && a.IsPublished) + .OrderByDescending(a => a.CreatedAt) + .ToListAsync(); + + return Ok(announcements.Select(MapToAnnouncementDto)); + } + + private Guid? GetCurrentUserId() + { + var userIdClaim = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; + if (Guid.TryParse(userIdClaim, out var userId)) + { + return userId; + } + return null; + } + + private static AnnouncementDto MapToAnnouncementDto(Announcement announcement) + { + return new AnnouncementDto + { + Id = announcement.Id, + EventId = announcement.EventId, + EventName = announcement.Event?.Name ?? string.Empty, + Title = announcement.Title, + Content = announcement.Content, + AuthorId = announcement.AuthorId, + AuthorName = announcement.Author?.Name ?? string.Empty, + CreatedAt = announcement.CreatedAt, + UpdatedAt = announcement.UpdatedAt, + IsPublished = announcement.IsPublished + }; + } +} \ No newline at end of file diff --git a/backend/Controllers/DashboardController.cs b/backend/Controllers/DashboardController.cs new file mode 100644 index 0000000..06f433c --- /dev/null +++ b/backend/Controllers/DashboardController.cs @@ -0,0 +1,167 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using RacePlannerApi.Data; +using RacePlannerApi.DTOs; +using RacePlannerApi.Models; + +namespace RacePlannerApi.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Authorize] +public class DashboardController : ControllerBase +{ + private readonly RacePlannerDbContext _context; + + public DashboardController(RacePlannerDbContext context) + { + _context = context; + } + + [HttpGet("organizer")] + [Authorize(Roles = "Organizer")] + public async Task> GetOrganizerDashboard() + { + var userId = GetCurrentUserId(); + if (userId == null) + { + return Unauthorized(); + } + + var events = await _context.Events + .Include(e => e.Registrations) + .ThenInclude(r => r.Payments) + .Where(e => e.OrganizerId == userId.Value) + .ToListAsync(); + + var totalEvents = events.Count; + var publishedEvents = events.Count(e => e.Status == EventStatus.Published); + var draftEvents = events.Count(e => e.Status == EventStatus.Draft); + + var allRegistrations = events.SelectMany(e => e.Registrations).ToList(); + var totalRegistrations = allRegistrations.Count; + var pendingRegistrations = allRegistrations.Count(r => r.Status == RegistrationStatus.Pending); + var confirmedRegistrations = allRegistrations.Count(r => r.Status == RegistrationStatus.Confirmed); + var cancelledRegistrations = allRegistrations.Count(r => r.Status == RegistrationStatus.Cancelled); + + var totalRevenue = allRegistrations + .SelectMany(r => r.Payments) + .Sum(p => p.Amount); + + var upcomingEvents = events + .Where(e => e.EventDate >= DateTime.UtcNow && e.EventDate <= DateTime.UtcNow.AddDays(30)) + .OrderBy(e => e.EventDate) + .Take(5) + .Select(e => new EventSummaryDto + { + Id = e.Id, + Name = e.Name, + EventDate = e.EventDate, + Location = e.Location, + RegistrationCount = e.Registrations.Count(r => r.Status != RegistrationStatus.Cancelled), + MaxParticipants = e.MaxParticipants + }) + .ToList(); + + var eventsNearCapacity = events + .Where(e => e.MaxParticipants.HasValue && e.Status == EventStatus.Published) + .Select(e => new EventCapacityDto + { + Id = e.Id, + Name = e.Name, + EventDate = e.EventDate, + RegistrationCount = e.Registrations.Count(r => r.Status != RegistrationStatus.Cancelled), + MaxParticipants = e.MaxParticipants + }) + .Where(e => e.CapacityPercentage >= 80) + .OrderByDescending(e => e.CapacityPercentage) + .Take(5) + .ToList(); + + var dashboard = new OrganizerDashboardDto + { + TotalEvents = totalEvents, + PublishedEvents = publishedEvents, + DraftEvents = draftEvents, + TotalRegistrations = totalRegistrations, + PendingRegistrations = pendingRegistrations, + ConfirmedRegistrations = confirmedRegistrations, + CancelledRegistrations = cancelledRegistrations, + TotalRevenue = totalRevenue, + UpcomingEvents = upcomingEvents, + EventsNearCapacity = eventsNearCapacity + }; + + return Ok(dashboard); + } + + [HttpGet("participant")] + public async Task> GetParticipantDashboard() + { + var userId = GetCurrentUserId(); + if (userId == null) + { + return Unauthorized(); + } + + var registrations = await _context.Registrations + .Include(r => r.Event) + .Where(r => r.ParticipantId == userId.Value) + .ToListAsync(); + + var totalRegistrations = registrations.Count; + var upcomingEvents = registrations.Count(r => r.Event.EventDate >= DateTime.UtcNow && r.Status != RegistrationStatus.Cancelled); + var completedEvents = registrations.Count(r => r.Event.EventDate < DateTime.UtcNow && r.Status == RegistrationStatus.Completed); + var cancelledRegistrations = registrations.Count(r => r.Status == RegistrationStatus.Cancelled); + + var registrationSummaries = registrations + .OrderByDescending(r => r.Event.EventDate) + .Take(10) + .Select(r => new RegistrationSummaryDto + { + Id = r.Id, + EventId = r.EventId, + EventName = r.Event.Name, + EventDate = r.Event.EventDate, + Status = r.Status.ToString(), + RegisteredAt = r.CreatedAt + }) + .ToList(); + + var upcomingEventList = registrations + .Where(r => r.Event.EventDate >= DateTime.UtcNow && r.Status != RegistrationStatus.Cancelled) + .OrderBy(r => r.Event.EventDate) + .Take(5) + .Select(r => new UpcomingEventDto + { + Id = r.EventId, + Name = r.Event.Name, + EventDate = r.Event.EventDate, + Location = r.Event.Location + }) + .ToList(); + + var dashboard = new ParticipantDashboardDto + { + TotalRegistrations = totalRegistrations, + UpcomingEvents = upcomingEvents, + CompletedEvents = completedEvents, + CancelledRegistrations = cancelledRegistrations, + MyRegistrations = registrationSummaries, + UpcomingEventList = upcomingEventList + }; + + return Ok(dashboard); + } + + private Guid? GetCurrentUserId() + { + var userIdClaim = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; + if (Guid.TryParse(userIdClaim, out var userId)) + { + return userId; + } + return null; + } +} \ No newline at end of file diff --git a/backend/DTOs/AnnouncementDtos.cs b/backend/DTOs/AnnouncementDtos.cs new file mode 100644 index 0000000..dff894c --- /dev/null +++ b/backend/DTOs/AnnouncementDtos.cs @@ -0,0 +1,42 @@ +using System.ComponentModel.DataAnnotations; + +namespace RacePlannerApi.DTOs; + +public class CreateAnnouncementRequest +{ + [Required] + public Guid EventId { get; set; } + + [Required] + [MaxLength(200)] + public string Title { get; set; } = string.Empty; + + [Required] + [MaxLength(5000)] + public string Content { get; set; } = string.Empty; +} + +public class UpdateAnnouncementRequest +{ + [MaxLength(200)] + public string? Title { get; set; } + + [MaxLength(5000)] + public string? Content { get; set; } + + public bool? IsPublished { get; set; } +} + +public class AnnouncementDto +{ + public Guid Id { get; set; } + public Guid EventId { get; set; } + public string EventName { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; + public Guid AuthorId { get; set; } + public string AuthorName { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } + public bool IsPublished { get; set; } +} \ No newline at end of file diff --git a/backend/DTOs/DashboardDtos.cs b/backend/DTOs/DashboardDtos.cs new file mode 100644 index 0000000..a254340 --- /dev/null +++ b/backend/DTOs/DashboardDtos.cs @@ -0,0 +1,66 @@ +namespace RacePlannerApi.DTOs; + +public class OrganizerDashboardDto +{ + public int TotalEvents { get; set; } + public int PublishedEvents { get; set; } + public int DraftEvents { get; set; } + public int TotalRegistrations { get; set; } + public int PendingRegistrations { get; set; } + public int ConfirmedRegistrations { get; set; } + public int CancelledRegistrations { get; set; } + public decimal TotalRevenue { get; set; } + public List UpcomingEvents { get; set; } = new(); + public List EventsNearCapacity { get; set; } = new(); +} + +public class EventSummaryDto +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public DateTime EventDate { get; set; } + public string Location { get; set; } = string.Empty; + public int RegistrationCount { get; set; } + public int? MaxParticipants { get; set; } +} + +public class EventCapacityDto +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public DateTime EventDate { get; set; } + public int RegistrationCount { get; set; } + public int? MaxParticipants { get; set; } + public double CapacityPercentage => MaxParticipants.HasValue && MaxParticipants.Value > 0 + ? (double)RegistrationCount / MaxParticipants.Value * 100 + : 0; +} + +public class ParticipantDashboardDto +{ + public int TotalRegistrations { get; set; } + public int UpcomingEvents { get; set; } + public int CompletedEvents { get; set; } + public int CancelledRegistrations { get; set; } + public List MyRegistrations { get; set; } = new(); + public List UpcomingEventList { get; set; } = new(); +} + +public class RegistrationSummaryDto +{ + public Guid Id { get; set; } + public Guid EventId { get; set; } + public string EventName { get; set; } = string.Empty; + public DateTime EventDate { get; set; } + public string Status { get; set; } = string.Empty; + public DateTime RegisteredAt { get; set; } +} + +public class UpcomingEventDto +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public DateTime EventDate { get; set; } + public string Location { get; set; } = string.Empty; + public int DaysUntil => (EventDate - DateTime.UtcNow).Days; +} \ No newline at end of file diff --git a/openspec/changes/new-raceplanner-app/tasks.md b/openspec/changes/new-raceplanner-app/tasks.md index 05d8a21..9885921 100644 --- a/openspec/changes/new-raceplanner-app/tasks.md +++ b/openspec/changes/new-raceplanner-app/tasks.md @@ -18,20 +18,20 @@ ## 3. User Authentication (user-auth) -- [ ] 3.1 Implement user registration endpoint -- [ ] 3.2 Implement user login endpoint with JWT -- [ ] 3.3 Create authentication middleware -- [ ] 3.4 Implement role-based access control middleware +- [x] 3.1 Implement user registration endpoint +- [x] 3.2 Implement user login endpoint with JWT +- [x] 3.3 Create authentication middleware +- [x] 3.4 Implement role-based access control middleware - [ ] 3.5 Create registration form component - [ ] 3.6 Create login form component - [ ] 3.7 Implement logout functionality ## 4. Event Management (event-management) -- [ ] 4.1 Implement create event endpoint -- [ ] 4.2 Implement update event endpoint -- [ ] 4.3 Implement list events endpoint with filtering -- [ ] 4.4 Implement get event details endpoint +- [x] 4.1 Implement create event endpoint +- [x] 4.2 Implement update event endpoint +- [x] 4.3 Implement list events endpoint with filtering +- [x] 4.4 Implement get event details endpoint - [ ] 4.5 Create event creation form component - [ ] 4.6 Create event editing form component - [ ] 4.7 Create event list view with filters @@ -39,36 +39,36 @@ ## 5. Registration System (registration-system) -- [ ] 5.1 Implement registration endpoint -- [ ] 5.2 Implement registration status update endpoint -- [ ] 5.3 Implement cancel registration endpoint +- [x] 5.1 Implement registration endpoint +- [x] 5.2 Implement registration status update endpoint +- [x] 5.3 Implement cancel registration endpoint - [ ] 5.4 Create registration form component - [ ] 5.5 Create my registrations list component - [ ] 5.6 Implement registration status display ## 6. Payment Tracking (payment-tracking) -- [ ] 6.1 Implement record payment endpoint -- [ ] 6.2 Implement payment status endpoint -- [ ] 6.3 Implement payment report endpoint +- [x] 6.1 Implement record payment endpoint +- [x] 6.2 Implement payment status endpoint +- [x] 6.3 Implement payment report endpoint - [ ] 6.4 Create payment recording form component - [ ] 6.5 Create payment status display component - [ ] 6.6 Create payment report view ## 7. Announcements (announcements) -- [ ] 7.1 Implement create announcement endpoint -- [ ] 7.2 Implement edit announcement endpoint -- [ ] 7.3 Implement delete announcement endpoint -- [ ] 7.4 Implement list announcements endpoint +- [x] 7.1 Implement create announcement endpoint +- [x] 7.2 Implement edit announcement endpoint +- [x] 7.3 Implement delete announcement endpoint +- [x] 7.4 Implement list announcements endpoint - [ ] 7.5 Create announcement creation form - [ ] 7.6 Create announcement list component - [ ] 7.7 Implement notification system ## 8. Dashboard (dashboard) -- [ ] 8.1 Implement organizer metrics endpoint -- [ ] 8.2 Implement participant registrations endpoint +- [x] 8.1 Implement organizer metrics endpoint +- [x] 8.2 Implement participant registrations endpoint - [ ] 8.3 Create organizer dashboard component - [ ] 8.4 Create participant dashboard component - [ ] 8.5 Add quick action buttons