TripsController.cs
12,706 bytes
| 1 | using System.Security.Claims; |
|---|---|
| 2 | using Asp.Versioning; |
| 3 | using MediatR; |
| 4 | using Microsoft.AspNetCore.Authentication.JwtBearer; |
| 5 | using Microsoft.AspNetCore.Authorization; |
| 6 | using Microsoft.AspNetCore.Mvc; |
| 7 | using SplitApp.Modules.Trips.Api.Dto.v1; |
| 8 | using SplitApp.Modules.Trips.Application.Contracts; |
| 9 | using SplitApp.Modules.Trips.Domain.Entities; |
| 10 | using SplitApp.Modules.Trips.Domain.Enums; |
| 11 | using SplitApp.Shared.Contracts.Expenses.Commands; |
| 12 | using SplitApp.Shared.Contracts.Expenses.Queries; |
| 13 | using SplitApp.Shared.Contracts.Trips.Events; |
| 14 | using SplitApp.Shared.Messaging.Integration.Users; |
| 15 | |
| 16 | namespace SplitApp.Modules.Trips.Api.Controllers; |
| 17 | |
| 18 | [ApiVersion("1.0")] |
| 19 | [ApiController] |
| 20 | [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] |
| 21 | [Route("api/v{version:apiVersion}/[controller]")] |
| 22 | public class TripsController : ControllerBase |
| 23 | { |
| 24 | private readonly ITripsUnitOfWork _uow; |
| 25 | private readonly IMediator _mediator; |
| 26 | private readonly IUserLookup _users; |
| 27 | |
| 28 | public TripsController(ITripsUnitOfWork uow, IMediator mediator, IUserLookup users) |
| 29 | { |
| 30 | _uow = uow; |
| 31 | _mediator = mediator; |
| 32 | _users = users; |
| 33 | } |
| 34 | |
| 35 | [HttpGet] |
| 36 | public async Task<ActionResult<IEnumerable<TripDto>>> List() |
| 37 | { |
| 38 | var userId = CurrentUserId(); |
| 39 | if (userId == null) return Unauthorized(); |
| 40 | |
| 41 | var all = (await _uow.Trips.GetAllAsync()).ToList(); |
| 42 | var allParticipants = (await _uow.Participants.GetAllAsync()).ToList(); |
| 43 | var participantTripIds = allParticipants |
| 44 | .Where(p => p.UserId == userId.Value && p.IsActive) |
| 45 | .Select(p => p.TripId) |
| 46 | .ToHashSet(); |
| 47 | |
| 48 | var visible = all |
| 49 | .Where(t => t.CreatedById == userId.Value || participantTripIds.Contains(t.Id)) |
| 50 | .ToList(); |
| 51 | |
| 52 | // Cross-module currency lookup so frontend gets defaultCurrencyCode/Symbol. |
| 53 | var currencyIds = visible.Select(t => t.DefaultCurrencyId).Distinct().ToList(); |
| 54 | var currencies = (await _mediator.Send(new GetCurrenciesByIdsQuery(currencyIds))) |
| 55 | .ToDictionary(c => c.Id); |
| 56 | |
| 57 | // Per-trip participant counts, no embedded list (keeps payload small for index page). |
| 58 | var perTripParticipantCount = allParticipants |
| 59 | .Where(p => p.IsActive) |
| 60 | .GroupBy(p => p.TripId) |
| 61 | .ToDictionary(g => g.Key, g => g.Count()); |
| 62 | |
| 63 | return Ok(visible.Select(t => MapToDto(t, currencies, perTripParticipantCount.GetValueOrDefault(t.Id)))); |
| 64 | } |
| 65 | |
| 66 | [HttpGet("{id:guid}")] |
| 67 | public async Task<ActionResult<TripDto>> Get(Guid id) |
| 68 | { |
| 69 | var userId = CurrentUserId(); |
| 70 | if (userId == null) return Unauthorized(); |
| 71 | |
| 72 | var trip = await _uow.Trips.GetByIdAsync(id); |
| 73 | if (trip == null) return NotFound(); |
| 74 | if (!await CanReadAsync(trip, userId.Value)) return Forbid(); |
| 75 | |
| 76 | // Currency |
| 77 | var currencies = (await _mediator.Send(new GetCurrenciesByIdsQuery(new[] { trip.DefaultCurrencyId }))) |
| 78 | .ToDictionary(c => c.Id); |
| 79 | |
| 80 | // Participants + cross-module user name lookup |
| 81 | var allParticipants = await _uow.Participants.GetAllAsync(); |
| 82 | var tripParticipants = allParticipants |
| 83 | .Where(p => p.TripId == trip.Id) |
| 84 | .OrderBy(p => p.JoinedAt) |
| 85 | .ToList(); |
| 86 | var participantDtos = await BuildParticipantDtosAsync(tripParticipants); |
| 87 | |
| 88 | var dto = MapToDto(trip, currencies, tripParticipants.Count(p => p.IsActive)); |
| 89 | dto.Participants = participantDtos; |
| 90 | return Ok(dto); |
| 91 | } |
| 92 | |
| 93 | [HttpPost] |
| 94 | public async Task<ActionResult<TripDto>> Create([FromBody] TripCreateDto dto) |
| 95 | { |
| 96 | var userId = CurrentUserId(); |
| 97 | if (userId == null) return Unauthorized(); |
| 98 | |
| 99 | var trip = new Trip |
| 100 | { |
| 101 | Name = dto.Name, |
| 102 | Description = dto.Description, |
| 103 | Destination = dto.Destination, |
| 104 | StartDate = dto.StartDate, |
| 105 | EndDate = dto.EndDate, |
| 106 | DefaultCurrencyId = dto.DefaultCurrencyId, |
| 107 | CreatedById = userId.Value, |
| 108 | Status = ETripStatus.Active, |
| 109 | }; |
| 110 | _uow.Trips.Add(trip); |
| 111 | |
| 112 | _uow.Participants.Add(new TripParticipant |
| 113 | { |
| 114 | TripId = trip.Id, |
| 115 | UserId = userId.Value, |
| 116 | Role = EParticipantRole.Organizer, |
| 117 | JoinedAt = DateTime.UtcNow, |
| 118 | IsActive = true, |
| 119 | }); |
| 120 | |
| 121 | await _uow.SaveChangesAsync(); |
| 122 | |
| 123 | // Reload with full hydration so frontend gets a complete TripDto on POST response. |
| 124 | var currencies = (await _mediator.Send(new GetCurrenciesByIdsQuery(new[] { trip.DefaultCurrencyId }))) |
| 125 | .ToDictionary(c => c.Id); |
| 126 | var participants = (await _uow.Participants.GetAllAsync()) |
| 127 | .Where(p => p.TripId == trip.Id).OrderBy(p => p.JoinedAt).ToList(); |
| 128 | var participantDtos = await BuildParticipantDtosAsync(participants); |
| 129 | var responseDto = MapToDto(trip, currencies, participants.Count(p => p.IsActive)); |
| 130 | responseDto.Participants = participantDtos; |
| 131 | |
| 132 | return CreatedAtAction(nameof(Get), new { id = trip.Id, version = "1.0" }, responseDto); |
| 133 | } |
| 134 | |
| 135 | [HttpPut("{id:guid}")] |
| 136 | public async Task<IActionResult> Update(Guid id, [FromBody] TripUpdateDto dto) |
| 137 | { |
| 138 | var userId = CurrentUserId(); |
| 139 | if (userId == null) return Unauthorized(); |
| 140 | if (dto.Id != Guid.Empty && dto.Id != id) return BadRequest(); |
| 141 | |
| 142 | var trip = await _uow.Trips.GetByIdAsync(id); |
| 143 | if (trip == null) return NotFound(); |
| 144 | if (trip.CreatedById != userId.Value) return Forbid(); |
| 145 | |
| 146 | trip.Name = dto.Name; |
| 147 | trip.Description = dto.Description; |
| 148 | trip.Destination = dto.Destination; |
| 149 | trip.StartDate = dto.StartDate; |
| 150 | trip.EndDate = dto.EndDate; |
| 151 | trip.DefaultCurrencyId = dto.DefaultCurrencyId; |
| 152 | if (!string.IsNullOrEmpty(dto.Status) && Enum.TryParse<ETripStatus>(dto.Status, true, out var newStatus)) |
| 153 | { |
| 154 | trip.Status = newStatus; |
| 155 | } |
| 156 | _uow.Trips.Update(trip); |
| 157 | await _uow.SaveChangesAsync(); |
| 158 | return NoContent(); |
| 159 | } |
| 160 | |
| 161 | [HttpDelete("{id:guid}")] |
| 162 | public async Task<IActionResult> Delete(Guid id) |
| 163 | { |
| 164 | var userId = CurrentUserId(); |
| 165 | if (userId == null) return Unauthorized(); |
| 166 | |
| 167 | var trip = await _uow.Trips.GetByIdAsync(id); |
| 168 | if (trip == null) return NotFound(); |
| 169 | if (trip.CreatedById != userId.Value) return Forbid(); |
| 170 | |
| 171 | await _uow.Trips.RemoveAsync(id); |
| 172 | await _uow.SaveChangesAsync(); |
| 173 | |
| 174 | await _mediator.Publish(new TripDeletedEvent(id)); |
| 175 | return NoContent(); |
| 176 | } |
| 177 | |
| 178 | [HttpGet("{tripId:guid}/participants")] |
| 179 | public async Task<ActionResult<List<TripParticipantDto>>> GetParticipants(Guid tripId) |
| 180 | { |
| 181 | var userId = CurrentUserId(); |
| 182 | if (userId == null) return Unauthorized(); |
| 183 | |
| 184 | var trip = await _uow.Trips.GetByIdAsync(tripId); |
| 185 | if (trip == null) return NotFound(); |
| 186 | if (!await CanReadAsync(trip, userId.Value)) return Forbid(); |
| 187 | |
| 188 | var rows = (await _uow.Participants.GetAllAsync()) |
| 189 | .Where(p => p.TripId == tripId) |
| 190 | .OrderBy(p => p.JoinedAt) |
| 191 | .ToList(); |
| 192 | return Ok(await BuildParticipantDtosAsync(rows)); |
| 193 | } |
| 194 | |
| 195 | [HttpPost("{id:guid}/finalize")] |
| 196 | public async Task<IActionResult> Finalize(Guid id) |
| 197 | { |
| 198 | var userId = CurrentUserId(); |
| 199 | if (userId == null) return Unauthorized(); |
| 200 | |
| 201 | var trip = await _uow.Trips.GetByIdAsync(id); |
| 202 | if (trip == null) return NotFound(); |
| 203 | if (trip.CreatedById != userId.Value) return Forbid(); |
| 204 | if (trip.Status != ETripStatus.Active) return BadRequest(new { error = "Trip must be active to finalize." }); |
| 205 | |
| 206 | trip.Status = ETripStatus.Finalizing; |
| 207 | _uow.Trips.Update(trip); |
| 208 | await _uow.SaveChangesAsync(); |
| 209 | |
| 210 | // Auto-create the settlement plan + concrete payments (phase-2 parity). |
| 211 | // The Vue front shows real Mark Paid / Confirm Receipt UI only when latestPlan is non-null; |
| 212 | // without this step it falls back to a "preview" view with no per-user actions. |
| 213 | var planId = await _mediator.Send(new CalculateSettlementCommand(id, userId.Value)); |
| 214 | |
| 215 | // No outstanding balances → trip is already settled. Mark accordingly. |
| 216 | if (planId == null) |
| 217 | { |
| 218 | trip.Status = ETripStatus.Settled; |
| 219 | _uow.Trips.Update(trip); |
| 220 | await _uow.SaveChangesAsync(); |
| 221 | } |
| 222 | |
| 223 | return Ok(); |
| 224 | } |
| 225 | |
| 226 | [HttpPost("{id:guid}/reopen")] |
| 227 | public async Task<IActionResult> Reopen(Guid id) |
| 228 | { |
| 229 | var userId = CurrentUserId(); |
| 230 | if (userId == null) return Unauthorized(); |
| 231 | |
| 232 | var trip = await _uow.Trips.GetByIdAsync(id); |
| 233 | if (trip == null) return NotFound(); |
| 234 | if (trip.CreatedById != userId.Value) return Forbid(); |
| 235 | if (trip.Status != ETripStatus.Finalizing && trip.Status != ETripStatus.Settled) |
| 236 | return BadRequest(new { error = "Trip can only be reopened from Finalizing or Settled." }); |
| 237 | |
| 238 | // Drop the existing settlement plan + payments so the next Finalize starts fresh. |
| 239 | // Returns false if any payment is already Confirmed — phase-2 parity. |
| 240 | var removed = await _mediator.Send(new RemoveSettlementPlanCommand(id)); |
| 241 | if (!removed) |
| 242 | { |
| 243 | return BadRequest(new { error = "Cannot reopen: settlement has confirmed payments." }); |
| 244 | } |
| 245 | |
| 246 | trip.Status = ETripStatus.Active; |
| 247 | _uow.Trips.Update(trip); |
| 248 | await _uow.SaveChangesAsync(); |
| 249 | return Ok(); |
| 250 | } |
| 251 | |
| 252 | [HttpDelete("{tripId:guid}/participants/{userId:guid}")] |
| 253 | public async Task<IActionResult> RemoveParticipant(Guid tripId, Guid userId) |
| 254 | { |
| 255 | var currentUserId = CurrentUserId(); |
| 256 | if (currentUserId == null) return Unauthorized(); |
| 257 | |
| 258 | var trip = await _uow.Trips.GetByIdAsync(tripId); |
| 259 | if (trip == null) return NotFound(); |
| 260 | if (trip.CreatedById != currentUserId.Value) return Forbid(); |
| 261 | if (userId == currentUserId.Value) return BadRequest(new { error = "Cannot remove yourself." }); |
| 262 | |
| 263 | var participant = (await _uow.Participants.GetAllAsync()) |
| 264 | .FirstOrDefault(p => p.TripId == tripId && p.UserId == userId); |
| 265 | if (participant == null) return NotFound(); |
| 266 | if (participant.Role == EParticipantRole.Organizer) return BadRequest(new { error = "Cannot remove an organizer." }); |
| 267 | |
| 268 | participant.IsActive = false; |
| 269 | participant.LeftAt = DateTime.UtcNow; |
| 270 | _uow.Participants.Update(participant); |
| 271 | await _uow.SaveChangesAsync(); |
| 272 | return NoContent(); |
| 273 | } |
| 274 | |
| 275 | private Guid? CurrentUserId() |
| 276 | { |
| 277 | var raw = User.FindFirstValue(ClaimTypes.NameIdentifier); |
| 278 | return Guid.TryParse(raw, out var id) ? id : null; |
| 279 | } |
| 280 | |
| 281 | private async Task<bool> CanReadAsync(Trip trip, Guid userId) |
| 282 | { |
| 283 | if (trip.CreatedById == userId) return true; |
| 284 | var allParticipants = await _uow.Participants.GetAllAsync(); |
| 285 | return allParticipants.Any(p => p.TripId == trip.Id && p.UserId == userId && p.IsActive); |
| 286 | } |
| 287 | |
| 288 | private async Task<List<TripParticipantDto>> BuildParticipantDtosAsync(IList<TripParticipant> rows) |
| 289 | { |
| 290 | if (rows.Count == 0) return new List<TripParticipantDto>(); |
| 291 | var userIds = rows.Select(p => p.UserId).Distinct().ToList(); |
| 292 | var users = await _users.GetByIdsAsync(userIds); |
| 293 | return rows.Select(p => |
| 294 | { |
| 295 | users.TryGetValue(p.UserId, out var u); |
| 296 | return new TripParticipantDto |
| 297 | { |
| 298 | Id = p.Id, |
| 299 | TripId = p.TripId, |
| 300 | UserId = p.UserId, |
| 301 | UserName = u?.DisplayName, |
| 302 | UserEmail = u?.Email, |
| 303 | Role = p.Role.ToString(), |
| 304 | Nickname = p.Nickname, |
| 305 | JoinedAt = p.JoinedAt, |
| 306 | IsActive = p.IsActive, |
| 307 | }; |
| 308 | }).ToList(); |
| 309 | } |
| 310 | |
| 311 | private static TripDto MapToDto( |
| 312 | Trip trip, |
| 313 | IDictionary<Guid, SplitApp.Shared.Contracts.Expenses.CurrencyDto> currencies, |
| 314 | int participantCount) => new() |
| 315 | { |
| 316 | Id = trip.Id, |
| 317 | Name = trip.Name, |
| 318 | Description = trip.Description, |
| 319 | Destination = trip.Destination, |
| 320 | StartDate = trip.StartDate, |
| 321 | EndDate = trip.EndDate, |
| 322 | Status = trip.Status.ToString(), |
| 323 | DefaultCurrencyId = trip.DefaultCurrencyId, |
| 324 | DefaultCurrencyCode = currencies.TryGetValue(trip.DefaultCurrencyId, out var c) ? c.Code : null, |
| 325 | DefaultCurrencySymbol = currencies.TryGetValue(trip.DefaultCurrencyId, out var c2) ? c2.Symbol : null, |
| 326 | CreatedById = trip.CreatedById, |
| 327 | ParticipantCount = participantCount, |
| 328 | }; |
| 329 | } |
| 330 | |