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 | |