profileShare

rasmusjy / splitapp-backend-modular-monolith

Read-only snapshot

No repository description.

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