profileShare

rasmusjy / splitapp-backend-modular-monolith

Read-only snapshot

No repository description.

main default branch 418 files Expires Sep 13, 2026, 9:06 AM
AppUnitOfWork.cs 28,791 bytes
1 using Microsoft.EntityFrameworkCore;
2 using SplitApp.Modules.Expenses.Domain.Entities;
3 using SplitApp.Modules.Expenses.Infrastructure.Persistence;
4 using SplitApp.Modules.Trips.Domain.Entities;
5 using SplitApp.Modules.Trips.Infrastructure.Persistence;
6 using SplitApp.Modules.Users.Domain.Entities;
7 using SplitApp.Modules.Users.Infrastructure.Persistence;
8 using SplitApp.Shared.Kernel.Domain;
9 using SplitApp.Shared.Kernel.Persistence;
10 using SplitApp.WebApp.Application.Contracts;
11
12 namespace SplitApp.WebApp.Application.Persistence;
13
14 /// <summary>
15 /// Composition-root unit-of-work that aggregates the three module DbContexts. The
16 /// repositories below each route to the appropriate module DbContext. Cross-module
17 /// joins happen in C# (LINQ) rather than SQL — modules still own their schemas.
18 /// Modules themselves never see this; only the WebApp host does.
19 /// </summary>
20 public class AppUnitOfWork : IAppUnitOfWork
21 {
22 private readonly TripsDbContext _tripsDb;
23 private readonly ExpensesDbContext _expensesDb;
24 private readonly UsersDbContext _usersDb;
25
26 public AppUnitOfWork(TripsDbContext tripsDb, ExpensesDbContext expensesDb, UsersDbContext usersDb)
27 {
28 _tripsDb = tripsDb;
29 _expensesDb = expensesDb;
30 _usersDb = usersDb;
31
32 // Repos that load entities with [NotMapped] cross-module navs (e.g. AppUser, Currency)
33 // receive the foreign DbContexts so they can hydrate those navs in C# after the
34 // primary query — EF can't follow them because the FK crosses Postgres schemas.
35 Trips = new TripRepo(tripsDb, usersDb, expensesDb);
36 Expenses = new ExpenseRepo(expensesDb, usersDb, tripsDb);
37 TripParticipants = new TripParticipantRepo(tripsDb, usersDb);
38 TripInvitations = new TripInvitationRepo(tripsDb, usersDb);
39 SettlementPlans = new SettlementPlanRepo(expensesDb, usersDb, tripsDb);
40 SettlementPayments = new SettlementPaymentRepo(expensesDb, usersDb);
41 TripPolls = new TripPollRepo(tripsDb, usersDb);
42 TripWishlistItems = new TripWishlistItemRepo(tripsDb, usersDb);
43 SplitPresets = new SplitPresetRepo(expensesDb, usersDb, tripsDb);
44 BudgetCategories = new BudgetCategoryRepo(tripsDb);
45 RefreshTokens = new RefreshTokenRepo(usersDb);
46 Users = new UserRepo(usersDb);
47 }
48
49 public ITripRepository Trips { get; }
50 public IExpenseRepository Expenses { get; }
51 public ITripParticipantRepository TripParticipants { get; }
52 public ITripInvitationRepository TripInvitations { get; }
53 public ISettlementPlanRepository SettlementPlans { get; }
54 public ISettlementPaymentRepository SettlementPayments { get; }
55 public ITripPollRepository TripPolls { get; }
56 public ITripWishlistItemRepository TripWishlistItems { get; }
57 public ISplitPresetRepository SplitPresets { get; }
58 public IBudgetCategoryRepository BudgetCategories { get; }
59 public IRefreshTokenRepository RefreshTokens { get; }
60 public IUserRepository Users { get; }
61
62 public async Task<int> SaveChangesAsync()
63 {
64 var trips = await _tripsDb.SaveChangesAsync();
65 var expenses = await _expensesDb.SaveChangesAsync();
66 var users = await _usersDb.SaveChangesAsync();
67 return trips + expenses + users;
68 }
69
70 public IBaseRepository<TEntity> GetRepository<TEntity>() where TEntity : class, IBaseEntity
71 {
72 var t = typeof(TEntity);
73 if (t == typeof(SettlementPayment)) return (IBaseRepository<TEntity>)SettlementPayments;
74 if (t == typeof(SettlementPlan)) return (IBaseRepository<TEntity>)SettlementPlans;
75 if (t == typeof(Trip)) return (IBaseRepository<TEntity>)Trips;
76 if (t == typeof(TripParticipant)) return (IBaseRepository<TEntity>)TripParticipants;
77 if (t == typeof(Expense)) return (IBaseRepository<TEntity>)Expenses;
78 if (t == typeof(TripInvitation)) return (IBaseRepository<TEntity>)TripInvitations;
79 if (t == typeof(BudgetCategory)) return (IBaseRepository<TEntity>)BudgetCategories;
80 if (t == typeof(TripPoll)) return (IBaseRepository<TEntity>)TripPolls;
81 if (t == typeof(TripWishlistItem)) return (IBaseRepository<TEntity>)TripWishlistItems;
82 if (t == typeof(SplitPreset)) return (IBaseRepository<TEntity>)SplitPresets;
83 if (t == typeof(AppRefreshToken)) return (IBaseRepository<TEntity>)RefreshTokens;
84 if (t == typeof(AppUser)) return (IBaseRepository<TEntity>)Users;
85 // Generic per-DbContext repos for entities BLL services touch via GetRepository<T>
86 // but that aren't called out in the typed properties above.
87 if (t == typeof(Currency)) return new GenericExpensesRepo<Currency>(_expensesDb) as IBaseRepository<TEntity> ?? throw new InvalidOperationException();
88 if (t == typeof(ExpenseSplit)) return new GenericExpensesRepo<ExpenseSplit>(_expensesDb) as IBaseRepository<TEntity> ?? throw new InvalidOperationException();
89 if (t == typeof(SplitPresetMember)) return new GenericExpensesRepo<SplitPresetMember>(_expensesDb) as IBaseRepository<TEntity> ?? throw new InvalidOperationException();
90 if (t == typeof(TripPollOption)) return new GenericTripsRepo<TripPollOption>(_tripsDb) as IBaseRepository<TEntity> ?? throw new InvalidOperationException();
91 if (t == typeof(TripPollVote)) return new GenericTripsRepo<TripPollVote>(_tripsDb) as IBaseRepository<TEntity> ?? throw new InvalidOperationException();
92 if (t == typeof(TripWishlistVote)) return new GenericTripsRepo<TripWishlistVote>(_tripsDb) as IBaseRepository<TEntity> ?? throw new InvalidOperationException();
93 throw new InvalidOperationException($"No repository registered for {t.Name}");
94 }
95 }
96
97 internal class GenericTripsRepo<TEntity> : GenericRepo<TEntity, TripsDbContext>
98 where TEntity : class, IBaseEntity
99 {
100 public GenericTripsRepo(TripsDbContext db) : base(db) { }
101 }
102
103 internal class GenericExpensesRepo<TEntity> : GenericRepo<TEntity, ExpensesDbContext>
104 where TEntity : class, IBaseEntity
105 {
106 public GenericExpensesRepo(ExpensesDbContext db) : base(db) { }
107 }
108
109 internal class GenericRepo<TEntity, TDbContext> : IBaseRepository<TEntity>
110 where TEntity : class, IBaseEntity
111 where TDbContext : DbContext
112 {
113 protected readonly TDbContext Db;
114 protected readonly DbSet<TEntity> Set;
115
116 protected GenericRepo(TDbContext db)
117 {
118 Db = db;
119 Set = db.Set<TEntity>();
120 }
121
122 public virtual async Task<IEnumerable<TEntity>> GetAllAsync() => await Set.ToListAsync();
123 public virtual async Task<TEntity?> GetByIdAsync(Guid id) => await Set.FirstOrDefaultAsync(e => e.Id == id);
124 public virtual TEntity Add(TEntity entity) => Set.Add(entity).Entity;
125 public virtual TEntity Update(TEntity entity) => Set.Update(entity).Entity;
126 public virtual async Task<TEntity?> RemoveAsync(Guid id)
127 {
128 var e = await GetByIdAsync(id);
129 if (e == null) return null;
130 return Set.Remove(e).Entity;
131 }
132 public virtual async Task<bool> ExistsAsync(Guid id) => await Set.AnyAsync(e => e.Id == id);
133 }
134
135 internal static class CrossModuleHydration
136 {
137 /// <summary>Batches a single AspNetUsers lookup, populates each item's User nav.</summary>
138 public static async Task HydrateUsersAsync<T>(
139 UsersDbContext usersDb,
140 IEnumerable<T> items,
141 Func<T, Guid> getUserId,
142 Action<T, AppUser?> setUser) where T : class
143 {
144 var list = items as IList<T> ?? items.ToList();
145 var ids = list.Select(getUserId).Where(id => id != Guid.Empty).Distinct().ToList();
146 if (ids.Count == 0) return;
147 var users = await usersDb.Users
148 .Where(u => ids.Contains(u.Id))
149 .ToDictionaryAsync(u => u.Id);
150 foreach (var item in list)
151 {
152 users.TryGetValue(getUserId(item), out var u);
153 setUser(item, u);
154 }
155 }
156
157 public static async Task HydrateUsersAsync<T>(
158 UsersDbContext usersDb,
159 IEnumerable<T> items,
160 Func<T, Guid?> getUserId,
161 Action<T, AppUser?> setUser) where T : class
162 {
163 var list = items as IList<T> ?? items.ToList();
164 var ids = list.Select(getUserId).Where(id => id is { } x && x != Guid.Empty).Select(id => id!.Value).Distinct().ToList();
165 if (ids.Count == 0) return;
166 var users = await usersDb.Users
167 .Where(u => ids.Contains(u.Id))
168 .ToDictionaryAsync(u => u.Id);
169 foreach (var item in list)
170 {
171 var id = getUserId(item);
172 if (id.HasValue && users.TryGetValue(id.Value, out var u)) setUser(item, u);
173 else setUser(item, null);
174 }
175 }
176
177 /// <summary>Batches a single Trips lookup, sets each item's Trip nav (boxed as object since
178 /// Expenses-module entities can't reference the Trips-module Trip type directly).</summary>
179 public static async Task HydrateTripsAsync<T>(
180 TripsDbContext tripsDb,
181 IEnumerable<T> items,
182 Func<T, Guid> getTripId,
183 Action<T, Trip?> setTrip) where T : class
184 {
185 var list = items as IList<T> ?? items.ToList();
186 var ids = list.Select(getTripId).Where(id => id != Guid.Empty).Distinct().ToList();
187 if (ids.Count == 0) return;
188 var trips = await tripsDb.Trips
189 .Where(t => ids.Contains(t.Id))
190 .ToDictionaryAsync(t => t.Id);
191 foreach (var item in list)
192 {
193 trips.TryGetValue(getTripId(item), out var t);
194 setTrip(item, t);
195 }
196 }
197 }
198
199 internal class TripRepo : GenericRepo<Trip, TripsDbContext>, ITripRepository
200 {
201 private readonly UsersDbContext _users;
202 private readonly ExpensesDbContext _expenses;
203
204 public TripRepo(TripsDbContext db, UsersDbContext users, ExpensesDbContext expenses) : base(db)
205 {
206 _users = users;
207 _expenses = expenses;
208 }
209
210 private async Task HydrateTripAsync(Trip trip)
211 {
212 // Trip-level navs
213 var creator = await _users.Users.FirstOrDefaultAsync(u => u.Id == trip.CreatedById);
214 trip.CreatedBy = creator;
215 trip.DefaultCurrency = await _expenses.Currencies.FirstOrDefaultAsync(c => c.Id == trip.DefaultCurrencyId);
216
217 if (trip.Participants is { Count: > 0 } parts)
218 await CrossModuleHydration.HydrateUsersAsync(_users, parts, p => p.UserId, (p, u) => p.User = u);
219 if (trip.Invitations is { Count: > 0 } invs)
220 await CrossModuleHydration.HydrateUsersAsync(_users, invs, i => i.InvitedByUserId, (i, u) => i.InvitedByUser = u);
221 if (trip.Polls is { Count: > 0 } polls)
222 await CrossModuleHydration.HydrateUsersAsync(_users, polls, p => p.CreatedByUserId, (p, u) => p.CreatedByUser = u);
223 if (trip.WishlistItems is { Count: > 0 } wls)
224 await CrossModuleHydration.HydrateUsersAsync(_users, wls, w => w.AddedByUserId, (w, u) => w.AddedByUser = u);
225 }
226
227 public override async Task<Trip?> GetByIdAsync(Guid id)
228 {
229 var trip = await base.GetByIdAsync(id);
230 if (trip != null) await HydrateTripAsync(trip);
231 return trip;
232 }
233
234 public override async Task<IEnumerable<Trip>> GetAllAsync()
235 {
236 var trips = (await Db.Trips.ToListAsync());
237 await CrossModuleHydration.HydrateUsersAsync(_users, trips, t => t.CreatedById, (t, u) => t.CreatedBy = u);
238 var currencyIds = trips.Select(t => t.DefaultCurrencyId).Where(id => id != Guid.Empty).Distinct().ToList();
239 if (currencyIds.Count > 0)
240 {
241 var currencies = await _expenses.Currencies
242 .Where(c => currencyIds.Contains(c.Id))
243 .ToDictionaryAsync(c => c.Id);
244 foreach (var t in trips)
245 {
246 if (currencies.TryGetValue(t.DefaultCurrencyId, out var c)) t.DefaultCurrency = c;
247 }
248 }
249 return trips;
250 }
251
252 public async Task<IEnumerable<Trip>> GetUserTripsAsync(Guid userId)
253 {
254 var trips = await Db.Trips
255 .Include(t => t.Participants)
256 .Where(t => t.CreatedById == userId
257 || (t.Participants != null
258 && t.Participants.Any(p => p.UserId == userId && p.IsActive)))
259 .ToListAsync();
260 foreach (var t in trips) await HydrateTripAsync(t);
261 return trips;
262 }
263
264 public async Task<Trip?> GetByIdWithDetailsAsync(Guid id)
265 {
266 var trip = await Db.Trips
267 .Include(t => t.Participants)
268 .Include(t => t.BudgetCategories)
269 .Include(t => t.WishlistItems)
270 .Include(t => t.Polls)!.ThenInclude(p => p.Options)
271 .Include(t => t.Invitations)
272 .FirstOrDefaultAsync(t => t.Id == id);
273 if (trip != null) await HydrateTripAsync(trip);
274 return trip;
275 }
276 }
277
278 internal class ExpenseRepo : GenericRepo<Expense, ExpensesDbContext>, IExpenseRepository
279 {
280 private readonly UsersDbContext _users;
281 private readonly TripsDbContext _trips;
282 public ExpenseRepo(ExpensesDbContext db, UsersDbContext users, TripsDbContext trips) : base(db)
283 {
284 _users = users;
285 _trips = trips;
286 }
287
288 public override async Task<IEnumerable<Expense>> GetAllAsync()
289 {
290 var expenses = await Db.Expenses.Include(e => e.Currency).ToListAsync();
291 await CrossModuleHydration.HydrateUsersAsync(_users, expenses, e => e.PaidByUserId, (e, u) => e.PaidByUser = u);
292 await CrossModuleHydration.HydrateTripsAsync(_trips, expenses, e => e.TripId, (e, t) => e.Trip = t);
293 return expenses;
294 }
295
296 public override async Task<Expense?> GetByIdAsync(Guid id)
297 {
298 var e = await Db.Expenses.Include(x => x.Currency).FirstOrDefaultAsync(x => x.Id == id);
299 if (e != null)
300 {
301 await CrossModuleHydration.HydrateUsersAsync(_users, new[] { e }, x => x.PaidByUserId, (x, u) => x.PaidByUser = u);
302 await CrossModuleHydration.HydrateTripsAsync(_trips, new[] { e }, x => x.TripId, (x, t) => x.Trip = t);
303 }
304 return e;
305 }
306
307 public async Task<IEnumerable<Expense>> GetByTripIdAsync(Guid tripId)
308 {
309 var expenses = await Db.Expenses
310 .Include(e => e.Currency)
311 .Where(e => e.TripId == tripId)
312 .ToListAsync();
313 await CrossModuleHydration.HydrateUsersAsync(_users, expenses, e => e.PaidByUserId, (e, u) => e.PaidByUser = u);
314 return expenses;
315 }
316
317 public async Task<Expense?> GetByIdWithDetailsAsync(Guid id)
318 {
319 var expense = await Db.Expenses
320 .Include(e => e.Currency)
321 .Include(e => e.Splits)
322 .FirstOrDefaultAsync(e => e.Id == id);
323 if (expense == null) return null;
324
325 await CrossModuleHydration.HydrateUsersAsync(_users, new[] { expense }, e => e.PaidByUserId, (e, u) => e.PaidByUser = u);
326 if (expense.Splits is { Count: > 0 } splits)
327 await CrossModuleHydration.HydrateUsersAsync(_users, splits, s => s.UserId, (s, u) => s.User = u);
328 return expense;
329 }
330 }
331
332 internal class TripParticipantRepo : GenericRepo<TripParticipant, TripsDbContext>, ITripParticipantRepository
333 {
334 private readonly UsersDbContext _users;
335 public TripParticipantRepo(TripsDbContext db, UsersDbContext users) : base(db) { _users = users; }
336
337 public async Task<bool> IsParticipantAsync(Guid tripId, Guid userId)
338 => await Db.TripParticipants.AnyAsync(p => p.TripId == tripId && p.UserId == userId && p.IsActive);
339
340 public async Task<bool> IsOrganizerAsync(Guid tripId, Guid userId)
341 {
342 var trip = await Db.Trips.FirstOrDefaultAsync(t => t.Id == tripId);
343 if (trip == null) return false;
344 if (trip.CreatedById == userId) return true;
345 return await Db.TripParticipants.AnyAsync(p =>
346 p.TripId == tripId && p.UserId == userId && p.IsActive
347 && p.Role == SplitApp.Modules.Trips.Domain.Enums.EParticipantRole.Organizer);
348 }
349
350 public async Task<IEnumerable<TripParticipant>> GetByTripIdAsync(Guid tripId)
351 {
352 var parts = await Db.TripParticipants.Where(p => p.TripId == tripId).ToListAsync();
353 await CrossModuleHydration.HydrateUsersAsync(_users, parts, p => p.UserId, (p, u) => p.User = u);
354 return parts;
355 }
356
357 public override async Task<TripParticipant?> GetByIdAsync(Guid id)
358 {
359 var p = await Db.TripParticipants.Include(x => x.Trip).FirstOrDefaultAsync(x => x.Id == id);
360 if (p != null)
361 await CrossModuleHydration.HydrateUsersAsync(_users, new[] { p }, x => x.UserId, (x, u) => x.User = u);
362 return p;
363 }
364
365 public override async Task<IEnumerable<TripParticipant>> GetAllAsync()
366 {
367 var parts = await Db.TripParticipants.Include(p => p.Trip).ToListAsync();
368 await CrossModuleHydration.HydrateUsersAsync(_users, parts, p => p.UserId, (p, u) => p.User = u);
369 return parts;
370 }
371 }
372
373 internal class TripInvitationRepo : GenericRepo<TripInvitation, TripsDbContext>, ITripInvitationRepository
374 {
375 private readonly UsersDbContext _users;
376 public TripInvitationRepo(TripsDbContext db, UsersDbContext users) : base(db) { _users = users; }
377
378 public override async Task<IEnumerable<TripInvitation>> GetAllAsync()
379 {
380 var invs = await Db.TripInvitations.Include(i => i.Trip).ToListAsync();
381 await CrossModuleHydration.HydrateUsersAsync(_users, invs, i => i.InvitedByUserId, (i, u) => i.InvitedByUser = u);
382 return invs;
383 }
384
385 public async Task<TripInvitation?> GetByTokenAsync(string token)
386 {
387 var inv = await Db.TripInvitations.Include(i => i.Trip).FirstOrDefaultAsync(i => i.Token == token);
388 if (inv != null)
389 await CrossModuleHydration.HydrateUsersAsync(_users, new[] { inv }, i => i.InvitedByUserId, (i, u) => i.InvitedByUser = u);
390 return inv;
391 }
392
393 public async Task<IEnumerable<TripInvitation>> GetPendingByTripIdAsync(Guid tripId)
394 {
395 var invs = await Db.TripInvitations
396 .Where(i => i.TripId == tripId
397 && i.Status == SplitApp.Modules.Trips.Domain.Enums.EInvitationStatus.Pending)
398 .ToListAsync();
399 await CrossModuleHydration.HydrateUsersAsync(_users, invs, i => i.InvitedByUserId, (i, u) => i.InvitedByUser = u);
400 return invs;
401 }
402 }
403
404 internal class SettlementPlanRepo : GenericRepo<SettlementPlan, ExpensesDbContext>, ISettlementPlanRepository
405 {
406 private readonly UsersDbContext _users;
407 private readonly TripsDbContext _trips;
408 public SettlementPlanRepo(ExpensesDbContext db, UsersDbContext users, TripsDbContext trips) : base(db)
409 {
410 _users = users;
411 _trips = trips;
412 }
413
414 public override async Task<IEnumerable<SettlementPlan>> GetAllAsync()
415 {
416 var plans = await Db.SettlementPlans.ToListAsync();
417 await CrossModuleHydration.HydrateUsersAsync(_users, plans, p => p.CreatedByUserId, (p, u) => p.CreatedByUser = u);
418 await CrossModuleHydration.HydrateTripsAsync(_trips, plans, p => p.TripId, (p, t) => p.Trip = t);
419 return plans;
420 }
421
422 private async Task HydratePlanAsync(SettlementPlan plan)
423 {
424 await CrossModuleHydration.HydrateUsersAsync(_users, new[] { plan }, p => p.CreatedByUserId, (p, u) => p.CreatedByUser = u);
425 await CrossModuleHydration.HydrateTripsAsync(_trips, new[] { plan }, p => p.TripId, (p, t) => p.Trip = t);
426 if (plan.Payments is { Count: > 0 } payments)
427 {
428 // Batch From + To together — same AspNetUsers query.
429 var ids = payments.SelectMany(p => new[] { p.FromUserId, p.ToUserId })
430 .Where(id => id != Guid.Empty).Distinct().ToList();
431 if (ids.Count > 0)
432 {
433 var users = await _users.Users.Where(u => ids.Contains(u.Id)).ToDictionaryAsync(u => u.Id);
434 foreach (var p in payments)
435 {
436 if (users.TryGetValue(p.FromUserId, out var fu)) p.FromUser = fu;
437 if (users.TryGetValue(p.ToUserId, out var tu)) p.ToUser = tu;
438 }
439 }
440 }
441 }
442
443 public override async Task<SettlementPlan?> GetByIdAsync(Guid id)
444 {
445 var plan = await Db.SettlementPlans.Include(p => p.Payments).FirstOrDefaultAsync(p => p.Id == id);
446 if (plan != null) await HydratePlanAsync(plan);
447 return plan;
448 }
449
450 public async Task<SettlementPlan?> GetLatestByTripIdAsync(Guid tripId)
451 {
452 var plan = await Db.SettlementPlans
453 .Include(p => p.Payments)
454 .Where(p => p.TripId == tripId)
455 .OrderByDescending(p => p.CreatedAt)
456 .FirstOrDefaultAsync();
457 if (plan != null) await HydratePlanAsync(plan);
458 return plan;
459 }
460
461 public async Task DeletePlanWithPaymentsAsync(Guid planId)
462 {
463 var payments = await Db.SettlementPayments.Where(p => p.SettlementPlanId == planId).ToListAsync();
464 Db.SettlementPayments.RemoveRange(payments);
465 var plan = await Db.SettlementPlans.FirstOrDefaultAsync(p => p.Id == planId);
466 if (plan != null) Db.SettlementPlans.Remove(plan);
467 await Db.SaveChangesAsync();
468 }
469 }
470
471 internal class SettlementPaymentRepo : GenericRepo<SettlementPayment, ExpensesDbContext>, ISettlementPaymentRepository
472 {
473 private readonly UsersDbContext _users;
474 public SettlementPaymentRepo(ExpensesDbContext db, UsersDbContext users) : base(db) { _users = users; }
475
476 public override async Task<IEnumerable<SettlementPayment>> GetAllAsync()
477 {
478 var payments = await Db.SettlementPayments.ToListAsync();
479 var ids = payments.SelectMany(p => new[] { p.FromUserId, p.ToUserId })
480 .Where(id => id != Guid.Empty).Distinct().ToList();
481 if (ids.Count > 0)
482 {
483 var users = await _users.Users.Where(u => ids.Contains(u.Id)).ToDictionaryAsync(u => u.Id);
484 foreach (var p in payments)
485 {
486 if (users.TryGetValue(p.FromUserId, out var fu)) p.FromUser = fu;
487 if (users.TryGetValue(p.ToUserId, out var tu)) p.ToUser = tu;
488 }
489 }
490 return payments;
491 }
492
493 public override async Task<SettlementPayment?> GetByIdAsync(Guid id)
494 {
495 var p = await base.GetByIdAsync(id);
496 if (p != null)
497 {
498 var ids = new[] { p.FromUserId, p.ToUserId }.Where(x => x != Guid.Empty).Distinct().ToList();
499 var users = await _users.Users.Where(u => ids.Contains(u.Id)).ToDictionaryAsync(u => u.Id);
500 if (users.TryGetValue(p.FromUserId, out var fu)) p.FromUser = fu;
501 if (users.TryGetValue(p.ToUserId, out var tu)) p.ToUser = tu;
502 }
503 return p;
504 }
505 }
506
507 internal class TripPollRepo : GenericRepo<TripPoll, TripsDbContext>, ITripPollRepository
508 {
509 private readonly UsersDbContext _users;
510 public TripPollRepo(TripsDbContext db, UsersDbContext users) : base(db) { _users = users; }
511
512 public override async Task<IEnumerable<TripPoll>> GetAllAsync()
513 {
514 var polls = await Db.TripPolls.Include(p => p.Trip).ToListAsync();
515 await CrossModuleHydration.HydrateUsersAsync(_users, polls, p => p.CreatedByUserId, (p, u) => p.CreatedByUser = u);
516 return polls;
517 }
518
519 public async Task<IEnumerable<TripPoll>> GetByTripIdAsync(Guid tripId)
520 {
521 var polls = await Db.TripPolls
522 .Include(p => p.Options)!
523 .ThenInclude(o => o.Votes)
524 .Where(p => p.TripId == tripId)
525 .ToListAsync();
526 await CrossModuleHydration.HydrateUsersAsync(_users, polls, p => p.CreatedByUserId, (p, u) => p.CreatedByUser = u);
527 return polls;
528 }
529
530 public async Task<TripPoll?> GetByIdWithDetailsAsync(Guid id)
531 {
532 var poll = await Db.TripPolls
533 .Include(p => p.Options)!
534 .ThenInclude(o => o.Votes)
535 .FirstOrDefaultAsync(p => p.Id == id);
536 if (poll != null)
537 await CrossModuleHydration.HydrateUsersAsync(_users, new[] { poll }, p => p.CreatedByUserId, (p, u) => p.CreatedByUser = u);
538 return poll;
539 }
540 }
541
542 internal class TripWishlistItemRepo : GenericRepo<TripWishlistItem, TripsDbContext>, ITripWishlistItemRepository
543 {
544 private readonly UsersDbContext _users;
545 public TripWishlistItemRepo(TripsDbContext db, UsersDbContext users) : base(db) { _users = users; }
546
547 public override async Task<IEnumerable<TripWishlistItem>> GetAllAsync()
548 {
549 var items = await Db.TripWishlistItems.Include(i => i.Trip).ToListAsync();
550 await CrossModuleHydration.HydrateUsersAsync(_users, items, i => i.AddedByUserId, (i, u) => i.AddedByUser = u);
551 return items;
552 }
553
554 public async Task<IEnumerable<TripWishlistItem>> GetByTripIdAsync(Guid tripId)
555 {
556 var items = await Db.TripWishlistItems
557 .Include(i => i.Votes)
558 .Where(i => i.TripId == tripId)
559 .ToListAsync();
560 await CrossModuleHydration.HydrateUsersAsync(_users, items, i => i.AddedByUserId, (i, u) => i.AddedByUser = u);
561 return items;
562 }
563 }
564
565 internal class SplitPresetRepo : GenericRepo<SplitPreset, ExpensesDbContext>, ISplitPresetRepository
566 {
567 private readonly UsersDbContext _users;
568 private readonly TripsDbContext _trips;
569 public SplitPresetRepo(ExpensesDbContext db, UsersDbContext users, TripsDbContext trips) : base(db)
570 {
571 _users = users;
572 _trips = trips;
573 }
574
575 public override async Task<IEnumerable<SplitPreset>> GetAllAsync()
576 {
577 var presets = await Db.SplitPresets.ToListAsync();
578 await CrossModuleHydration.HydrateUsersAsync(_users, presets, p => p.CreatedById, (p, u) => p.CreatedBy = u);
579 await CrossModuleHydration.HydrateTripsAsync(_trips, presets, p => p.TripId, (p, t) => p.Trip = t);
580 return presets;
581 }
582
583 public async Task<IEnumerable<SplitPreset>> GetByTripIdAsync(Guid tripId)
584 {
585 var presets = await Db.SplitPresets
586 .Include(p => p.Members)
587 .Where(p => p.TripId == tripId)
588 .ToListAsync();
589 await CrossModuleHydration.HydrateUsersAsync(_users, presets, p => p.CreatedById, (p, u) => p.CreatedBy = u);
590 var members = presets.SelectMany(p => p.Members ?? Enumerable.Empty<SplitPresetMember>()).ToList();
591 if (members.Count > 0)
592 await CrossModuleHydration.HydrateUsersAsync(_users, members, m => m.UserId, (m, u) => m.User = u);
593 return presets;
594 }
595 }
596
597 internal class BudgetCategoryRepo : GenericRepo<BudgetCategory, TripsDbContext>, IBudgetCategoryRepository
598 {
599 public BudgetCategoryRepo(TripsDbContext db) : base(db) { }
600
601 public async Task<IEnumerable<BudgetCategory>> GetByTripIdAsync(Guid tripId)
602 => await Db.BudgetCategories.Where(c => c.TripId == tripId).ToListAsync();
603 }
604
605 internal class RefreshTokenRepo : GenericRepo<AppRefreshToken, UsersDbContext>, IRefreshTokenRepository
606 {
607 public RefreshTokenRepo(UsersDbContext db) : base(db) { }
608
609 public async Task<IEnumerable<AppRefreshToken>> GetUserActiveTokensAsync(Guid userId, string refreshTokenValue)
610 {
611 var now = DateTime.UtcNow;
612 return await Db.RefreshTokens
613 .Where(t => t.AppUserId == userId
614 && (t.RefreshToken == refreshTokenValue && t.ExpirationDT > now
615 || t.PreviousRefreshToken == refreshTokenValue && t.PreviousExpirationDT > now))
616 .ToListAsync();
617 }
618
619 public async Task<IEnumerable<AppRefreshToken>> GetUserTokensByValueAsync(Guid userId, string refreshTokenValue)
620 {
621 return await Db.RefreshTokens
622 .Where(t => t.AppUserId == userId
623 && (t.RefreshToken == refreshTokenValue || t.PreviousRefreshToken == refreshTokenValue))
624 .ToListAsync();
625 }
626
627 public async Task<int> RemoveExpiredForUserAsync(Guid userId)
628 {
629 var now = DateTime.UtcNow;
630 var expired = await Db.RefreshTokens
631 .Where(t => t.AppUserId == userId
632 && t.ExpirationDT < now
633 && t.PreviousExpirationDT < now)
634 .ToListAsync();
635 Db.RefreshTokens.RemoveRange(expired);
636 return expired.Count;
637 }
638
639 public void Remove(AppRefreshToken token) => Db.RefreshTokens.Remove(token);
640 }
641
642 internal class UserRepo : GenericRepo<AppUser, UsersDbContext>, IUserRepository
643 {
644 public UserRepo(UsersDbContext db) : base(db) { }
645
646 public async Task<int> CountAsync() => await Db.Users.CountAsync();
647
648 public async Task<IEnumerable<AppUser>> GetRecentAsync(int take)
649 => await Db.Users.OrderByDescending(u => u.Id).Take(take).ToListAsync();
650
651 public async Task<AppUser?> GetByIdWithRefreshTokensAsync(Guid userId)
652 => await Db.Users.Include(u => u.RefreshTokens).FirstOrDefaultAsync(u => u.Id == userId);
653 }
654