profileShare

rasmusjy / splitapp-backend-microservices

Read-only snapshot

No repository description.

main default branch 501 files Expires Sep 13, 2026, 9:06 AM
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