From 3733faec5a6e9b898019b56db6eca1b9845dbcda Mon Sep 17 00:00:00 2001 From: Peter Drier Date: Tue, 14 Apr 2026 18:34:32 +0200 Subject: [PATCH 1/2] Make TeamResourceService sole owner of google_resources (#491) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of the google_resources ownership cleanup: consumers stop reaching into DbSet directly and go through ITeamResourceService instead. Adds seven read methods (GetResourcesByTeamIdsAsync, GetTeamResourceSummariesAsync, GetActiveResourceCountsByTeamAsync, GetUserTeamResourcesAsync, GetActiveDriveFoldersAsync, GetResourceCountAsync, plus existing GetTeamResourcesAsync / GetResourceByIdAsync), migrates HumansMetricsService, DriveActivityMonitorService, GoogleAdminService, and TeamService (4 call sites + GetUserTeamGoogleResourcesAsync moved out), deletes TeamResourcePersistence, and removes the Team.GoogleResources EF navigation property. A CI guardrail (scripts/check-google-resource-ownership.sh) enforces that future changes keep DbSet access confined to TeamResourceService, its stub, the DbContext, and TeamConfiguration — GoogleWorkspaceSyncService and three known callers remain as Phase 2 exceptions pending a separate decomposition issue. TeamResourceService <-> TeamService is now mutually recursive for the GetUserTeamResourcesAsync read path, so both sides resolve each other lazily through IServiceProvider to avoid the DI cycle. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/DESIGN_RULES.md | 3 +- .github/workflows/build.yml | 3 + docs/architecture/service-data-access-map.md | 10 +- scripts/check-google-resource-ownership.sh | 68 ++++++++ .../Interfaces/ITeamResourceService.cs | 67 +++++++- .../Interfaces/ITeamService.cs | 13 -- src/Humans.Domain/Entities/Team.cs | 5 - .../Data/Configurations/TeamConfiguration.cs | 5 +- .../HumansDbContextModelSnapshot.cs | 4 +- .../Services/DriveActivityMonitorService.cs | 7 +- .../Services/GoogleAdminService.cs | 9 +- .../Services/GoogleWorkspaceSyncService.cs | 9 +- .../Services/HumansMetricsService.cs | 3 +- .../Services/StubTeamResourceService.cs | 154 ++++++++++++++++- .../Services/TeamResourcePersistence.cs | 49 ------ .../Services/TeamResourceService.cs | 156 +++++++++++++++++- .../Services/TeamService.cs | 66 ++++---- .../MyGoogleResourcesViewComponent.cs | 8 +- .../Services/GoogleAdminServiceTests.cs | 4 + .../Services/TeamRoleServiceTests.cs | 6 + .../Services/TeamServiceTests.cs | 6 + 21 files changed, 510 insertions(+), 145 deletions(-) create mode 100644 scripts/check-google-resource-ownership.sh delete mode 100644 src/Humans.Infrastructure/Services/TeamResourcePersistence.cs diff --git a/.claude/DESIGN_RULES.md b/.claude/DESIGN_RULES.md index 86d64a708..93173b688 100644 --- a/.claude/DESIGN_RULES.md +++ b/.claude/DESIGN_RULES.md @@ -57,7 +57,8 @@ Each section's service owns these tables. Cross-service access goes through the | **Budget** | `BudgetService` | `budget_years`, `budget_groups`, `budget_categories`, `budget_line_items`, `budget_audit_logs`, `ticketing_projections` | | **Tickets** | `TicketQueryService`, `TicketSyncService`, `TicketingBudgetService` | `ticket_orders`, `ticket_attendees`, `ticket_sync_states` | | **Campaigns** | `CampaignService` | `campaigns`, `campaign_codes`, `campaign_grants` | -| **Google Integration** | `GoogleSyncService`, `GoogleAdminService`, `GoogleWorkspaceUserService`, `DriveActivityMonitorService`, `SyncSettingsService`, `EmailProvisioningService` | `google_resources`, `sync_service_settings` | +| **Team Resources** | `TeamResourceService` | `google_resources` | +| **Google Integration** | `GoogleSyncService`, `GoogleAdminService`, `GoogleWorkspaceUserService`, `DriveActivityMonitorService`, `SyncSettingsService`, `EmailProvisioningService` | `sync_service_settings` | | **Email** | `EmailOutboxService`, `EmailService` | `email_outbox_messages` | | **Feedback** | `FeedbackService` | `feedback_reports` | | **Notifications** | `NotificationService`, `NotificationInboxService` | *(in-memory / transient)* | diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0ae05cb8d..abea3459b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -54,3 +54,6 @@ jobs: - name: Check formatting run: dotnet format Humans.slnx --verify-no-changes --verbosity diagnostic --exclude src/Humans.Infrastructure/Migrations/ + + - name: Enforce google_resources ownership + run: bash scripts/check-google-resource-ownership.sh diff --git a/docs/architecture/service-data-access-map.md b/docs/architecture/service-data-access-map.md index 3688d8b7a..5f1b9ea8b 100644 --- a/docs/architecture/service-data-access-map.md +++ b/docs/architecture/service-data-access-map.md @@ -237,13 +237,9 @@ No direct cache. Delegates to other services. |-------|-----| | GoogleResources | R/W | -No cache. - -### TeamResourcePersistence (static helper, not DI-registered) - -| Table | R/W | -|-------|-----| -| GoogleResources | R/W | +Sole owner of the `google_resources` table. All consumers call +`ITeamResourceService` read methods rather than touching `DbSet` +directly; ownership is enforced by `scripts/check-google-resource-ownership.sh`. No cache. diff --git a/scripts/check-google-resource-ownership.sh b/scripts/check-google-resource-ownership.sh new file mode 100644 index 000000000..42c1f7426 --- /dev/null +++ b/scripts/check-google-resource-ownership.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Guardrail: enforce that google_resources is accessed only through +# TeamResourceService (the owning service), per .claude/DESIGN_RULES.md. +# +# This script fails if any file under src/ touches DbSet +# or _dbContext.GoogleResources / dbContext.GoogleResources / db.GoogleResources +# outside the allowlisted owner files. Migration files and the DbContext +# itself are always allowed. +# +# Phase 2 exceptions: GoogleWorkspaceSyncService, SystemTeamSyncJob, +# ProcessGoogleSyncOutboxJob, and GoogleController still touch the table +# directly — they are tracked as temporary exceptions while the remainder +# of the Google sync service is decomposed. Remove entries from +# PHASE2_EXCEPTIONS as each caller migrates to ITeamResourceService reads. + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT_DIR" + +# Files that are allowed to touch the DbSet directly. +ALLOWED=( + "src/Humans.Infrastructure/Services/TeamResourceService.cs" + "src/Humans.Infrastructure/Services/StubTeamResourceService.cs" + "src/Humans.Infrastructure/Data/Configurations/TeamConfiguration.cs" + "src/Humans.Infrastructure/Data/HumansDbContext.cs" +) + +# Phase 2: still-pending migrations to services that own writes here. +# Track the upstream issue before adding anything to this list. +PHASE2_EXCEPTIONS=( + "src/Humans.Infrastructure/Services/GoogleWorkspaceSyncService.cs" + "src/Humans.Infrastructure/Jobs/SystemTeamSyncJob.cs" + "src/Humans.Infrastructure/Jobs/ProcessGoogleSyncOutboxJob.cs" + "src/Humans.Web/Controllers/GoogleController.cs" +) + +build_exclude_args() { + local args=() + for f in "${ALLOWED[@]}" "${PHASE2_EXCEPTIONS[@]}"; do + args+=(":(exclude)$f") + done + printf '%s\n' "${args[@]}" +} + +mapfile -t EXCLUDE_ARGS < <(build_exclude_args) + +# Dangerous patterns: direct DbSet access through a DbContext +# variable. We allow view-model/property/route names that also happen to +# contain the word "GoogleResources" — those never look like a DbContext +# member access. +PATTERN='(DbSet|(_dbContext|dbContext|db|_db)\.GoogleResources)' + +MATCHES=$(git grep -n -E "$PATTERN" -- 'src/**' "${EXCLUDE_ARGS[@]}" || true) + +if [[ -n "$MATCHES" ]]; then + echo "error: google_resources direct access outside TeamResourceService:" >&2 + echo "$MATCHES" >&2 + echo >&2 + echo "google_resources is owned by TeamResourceService. Use one of its read" >&2 + echo "methods (GetTeamResourcesAsync, GetResourcesByTeamIdsAsync," >&2 + echo "GetTeamResourceSummariesAsync, GetActiveResourceCountsByTeamAsync," >&2 + echo "GetUserTeamResourcesAsync, GetActiveDriveFoldersAsync, GetResourceCountAsync)" >&2 + echo "instead of reaching into the DbSet directly. See .claude/DESIGN_RULES.md." >&2 + exit 1 +fi + +echo "ok: google_resources access is confined to TeamResourceService." diff --git a/src/Humans.Application/Interfaces/ITeamResourceService.cs b/src/Humans.Application/Interfaces/ITeamResourceService.cs index 4ae28bde2..255b13b30 100644 --- a/src/Humans.Application/Interfaces/ITeamResourceService.cs +++ b/src/Humans.Application/Interfaces/ITeamResourceService.cs @@ -4,18 +4,83 @@ namespace Humans.Application.Interfaces; +/// +/// Aggregate summary of a team's Google resources, used by admin listings that need +/// per-team flags/counts without loading the full resource rows. +/// +public record TeamResourceSummary(bool HasMailGroup, int DriveResourceCount) +{ + public static TeamResourceSummary Empty { get; } = new(false, 0); +} + +/// +/// A Google resource projection joined with its owning team, used by the dashboard +/// "My Google Resources" widget. Owned by ITeamResourceService so callers do not +/// need to reach across the team ↔ resource boundary. +/// +public record UserTeamGoogleResource( + string TeamName, + string TeamSlug, + string ResourceName, + GoogleResourceType ResourceType, + string? Url); + /// /// Service for linking and managing pre-shared Google resources for teams. /// Unlike IGoogleSyncService (which provisions new resources), this service /// validates and links existing resources that have been pre-shared with the service account. +/// +/// This service is the sole owner of the google_resources table: callers must +/// never access DbSet<GoogleResource> directly and must go through the +/// read methods on this interface instead. /// public interface ITeamResourceService { /// - /// Gets all Google resources linked to a team. + /// Gets all active Google resources linked to a single team, ordered by provision time. /// Task> GetTeamResourcesAsync(Guid teamId, CancellationToken ct = default); + /// + /// Gets all active Google resources for a set of teams, grouped by team id. + /// Missing team ids map to an empty list in the returned dictionary. + /// + Task>> GetResourcesByTeamIdsAsync( + IReadOnlyCollection teamIds, + CancellationToken ct = default); + + /// + /// Gets aggregate summaries (mail group presence, drive resource count) for a set of teams. + /// Missing team ids map to . + /// + Task> GetTeamResourceSummariesAsync( + IReadOnlyCollection teamIds, + CancellationToken ct = default); + + /// + /// Returns the total active-resource count for every team that currently has any, + /// regardless of resource type. Used by admin aggregates (e.g. email rename impact). + /// + Task> GetActiveResourceCountsByTeamAsync(CancellationToken ct = default); + + /// + /// Gets the active Google resources visible to a user, joined with their team metadata. + /// Dashboard "My Google Resources" widget. + /// + Task> GetUserTeamResourcesAsync(Guid userId, CancellationToken ct = default); + + /// + /// Gets every active Drive folder resource across all teams. + /// Used by Drive activity anomaly detection. + /// + Task> GetActiveDriveFoldersAsync(CancellationToken ct = default); + + /// + /// Returns the total number of Google resource rows (including inactive). + /// Used by the observable metrics gauge. + /// + Task GetResourceCountAsync(CancellationToken ct = default); + /// /// Links an existing Google Drive folder to a team by URL. /// The folder must be pre-shared with the service account as Editor. diff --git a/src/Humans.Application/Interfaces/ITeamService.cs b/src/Humans.Application/Interfaces/ITeamService.cs index 7fc097085..b0ccbacbc 100644 --- a/src/Humans.Application/Interfaces/ITeamService.cs +++ b/src/Humans.Application/Interfaces/ITeamService.cs @@ -72,13 +72,6 @@ public record MyTeamMembershipSummary( bool CanLeave, int PendingRequestCount); -public record UserTeamGoogleResource( - string TeamName, - string TeamSlug, - string ResourceName, - GoogleResourceType ResourceType, - string? Url); - public record TeamRosterSlotSummary( string TeamName, string TeamSlug, @@ -171,12 +164,6 @@ Task CreateTeamAsync( /// Task> GetUserTeamsAsync(Guid userId, CancellationToken cancellationToken = default); - /// - /// Gets active Google resources grouped by team for a user's active team memberships. - /// Used by the MyGoogleResources view component on the dashboard. - /// - Task> GetUserTeamGoogleResourcesAsync(Guid userId, CancellationToken cancellationToken = default); - /// /// Gets the current user's team memberships with viewer-specific pending-request counts. /// diff --git a/src/Humans.Domain/Entities/Team.cs b/src/Humans.Domain/Entities/Team.cs index 1ee4981ff..0b43183dd 100644 --- a/src/Humans.Domain/Entities/Team.cs +++ b/src/Humans.Domain/Entities/Team.cs @@ -154,11 +154,6 @@ public class Team /// public ICollection JoinRequests { get; } = new List(); - /// - /// Navigation property to associated Google resources. - /// - public ICollection GoogleResources { get; } = new List(); - /// /// Navigation property to legal documents scoped to this team. /// diff --git a/src/Humans.Infrastructure/Data/Configurations/TeamConfiguration.cs b/src/Humans.Infrastructure/Data/Configurations/TeamConfiguration.cs index 3e154f924..d926df12f 100644 --- a/src/Humans.Infrastructure/Data/Configurations/TeamConfiguration.cs +++ b/src/Humans.Infrastructure/Data/Configurations/TeamConfiguration.cs @@ -107,7 +107,10 @@ public void Configure(EntityTypeBuilder builder) .HasForeignKey(jr => jr.TeamId) .OnDelete(DeleteBehavior.Cascade); - builder.HasMany(t => t.GoogleResources) + // google_resources is owned by TeamResourceService. The Team → GoogleResource + // navigation was removed to enforce ownership — the relationship is now + // configured from the GoogleResource side only via its Team nav property. + builder.HasMany() .WithOne(gr => gr.Team) .HasForeignKey(gr => gr.TeamId) .OnDelete(DeleteBehavior.SetNull); diff --git a/src/Humans.Infrastructure/Migrations/HumansDbContextModelSnapshot.cs b/src/Humans.Infrastructure/Migrations/HumansDbContextModelSnapshot.cs index 0ef9c5b10..9e86972e0 100644 --- a/src/Humans.Infrastructure/Migrations/HumansDbContextModelSnapshot.cs +++ b/src/Humans.Infrastructure/Migrations/HumansDbContextModelSnapshot.cs @@ -4009,7 +4009,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Humans.Domain.Entities.GoogleResource", b => { b.HasOne("Humans.Domain.Entities.Team", "Team") - .WithMany("GoogleResources") + .WithMany() .HasForeignKey("TeamId") .OnDelete(DeleteBehavior.SetNull) .IsRequired(); @@ -4562,8 +4562,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) { b.Navigation("ChildTeams"); - b.Navigation("GoogleResources"); - b.Navigation("JoinRequests"); b.Navigation("LegalDocuments"); diff --git a/src/Humans.Infrastructure/Services/DriveActivityMonitorService.cs b/src/Humans.Infrastructure/Services/DriveActivityMonitorService.cs index bf1719fbf..19d5c9c23 100644 --- a/src/Humans.Infrastructure/Services/DriveActivityMonitorService.cs +++ b/src/Humans.Infrastructure/Services/DriveActivityMonitorService.cs @@ -25,6 +25,7 @@ namespace Humans.Infrastructure.Services; public class DriveActivityMonitorService : IDriveActivityMonitorService { private readonly HumansDbContext _dbContext; + private readonly ITeamResourceService _teamResourceService; private readonly IAuditLogService _auditLogService; private readonly GoogleWorkspaceSettings _settings; private readonly IClock _clock; @@ -45,12 +46,14 @@ public class DriveActivityMonitorService : IDriveActivityMonitorService public DriveActivityMonitorService( HumansDbContext dbContext, + ITeamResourceService teamResourceService, IAuditLogService auditLogService, IOptions settings, IClock clock, ILogger logger) { _dbContext = dbContext; + _teamResourceService = teamResourceService; _auditLogService = auditLogService; _settings = settings.Value; _clock = clock; @@ -60,9 +63,7 @@ public DriveActivityMonitorService( /// public async Task CheckForAnomalousActivityAsync(CancellationToken cancellationToken = default) { - var resources = await _dbContext.GoogleResources - .Where(r => r.IsActive && r.ResourceType == GoogleResourceType.DriveFolder) - .ToListAsync(cancellationToken); + var resources = await _teamResourceService.GetActiveDriveFoldersAsync(cancellationToken); if (resources.Count == 0) { diff --git a/src/Humans.Infrastructure/Services/GoogleAdminService.cs b/src/Humans.Infrastructure/Services/GoogleAdminService.cs index db77fc183..6ef40c258 100644 --- a/src/Humans.Infrastructure/Services/GoogleAdminService.cs +++ b/src/Humans.Infrastructure/Services/GoogleAdminService.cs @@ -20,6 +20,7 @@ public class GoogleAdminService : IGoogleAdminService private readonly HumansDbContext _dbContext; private readonly IGoogleWorkspaceUserService _workspaceUserService; private readonly IGoogleSyncService _googleSyncService; + private readonly ITeamResourceService _teamResourceService; private readonly IUserEmailService _userEmailService; private readonly IAuditLogService _auditLogService; private readonly IClock _clock; @@ -31,6 +32,7 @@ public GoogleAdminService( HumansDbContext dbContext, IGoogleWorkspaceUserService workspaceUserService, IGoogleSyncService googleSyncService, + ITeamResourceService teamResourceService, IUserEmailService userEmailService, IAuditLogService auditLogService, IClock clock, @@ -39,6 +41,7 @@ public GoogleAdminService( _dbContext = dbContext; _workspaceUserService = workspaceUserService; _googleSyncService = googleSyncService; + _teamResourceService = teamResourceService; _userEmailService = userEmailService; _auditLogService = auditLogService; _clock = clock; @@ -444,11 +447,7 @@ public async Task DetectEmailRenamesAsync( .ToList(); // Load resource counts per team for affected resource calculation - var teamResourceCounts = await _dbContext.GoogleResources - .Where(r => r.IsActive) - .GroupBy(r => r.TeamId) - .Select(g => new { TeamId = g.Key, Count = g.Count() }) - .ToDictionaryAsync(x => x.TeamId, x => x.Count, ct); + var teamResourceCounts = await _teamResourceService.GetActiveResourceCountsByTeamAsync(ct); // Load active team memberships per user var userTeamIds = await _dbContext.TeamMembers diff --git a/src/Humans.Infrastructure/Services/GoogleWorkspaceSyncService.cs b/src/Humans.Infrastructure/Services/GoogleWorkspaceSyncService.cs index eafbd5b2b..fc4f5ae32 100644 --- a/src/Humans.Infrastructure/Services/GoogleWorkspaceSyncService.cs +++ b/src/Humans.Infrastructure/Services/GoogleWorkspaceSyncService.cs @@ -1642,7 +1642,6 @@ private async Task SyncDriveResourceGroupAsync( public async Task EnsureTeamGroupAsync(Guid teamId, bool confirmReactivation = false, CancellationToken cancellationToken = default) { var team = await _dbContext.Teams - .Include(t => t.GoogleResources) .FirstOrDefaultAsync(t => t.Id == teamId, cancellationToken); if (team is null) @@ -1651,8 +1650,12 @@ public async Task EnsureTeamGroupAsync(Guid teamId, bool confir return GroupLinkResult.Ok(); } - var existingGroup = team.GoogleResources - .FirstOrDefault(r => r.ResourceType == GoogleResourceType.Group && r.IsActive); + var existingGroup = await _dbContext.GoogleResources + .FirstOrDefaultAsync(r => + r.TeamId == teamId + && r.ResourceType == GoogleResourceType.Group + && r.IsActive, + cancellationToken); // If prefix was cleared, deactivate any active group resource if (team.GoogleGroupPrefix is null) diff --git a/src/Humans.Infrastructure/Services/HumansMetricsService.cs b/src/Humans.Infrastructure/Services/HumansMetricsService.cs index b4ccc8adb..e5572e74c 100644 --- a/src/Humans.Infrastructure/Services/HumansMetricsService.cs +++ b/src/Humans.Infrastructure/Services/HumansMetricsService.cs @@ -291,7 +291,8 @@ private async Task RefreshSnapshotAsync() .CountAsync(r => r.Status == TeamJoinRequestStatus.Pending); // google_resources - var googleResources = await db.GoogleResources.CountAsync(); + var teamResourceService = scope.ServiceProvider.GetRequiredService(); + var googleResources = await teamResourceService.GetResourceCountAsync(); // legal_documents_active var legalDocumentsActive = await db.LegalDocuments diff --git a/src/Humans.Infrastructure/Services/StubTeamResourceService.cs b/src/Humans.Infrastructure/Services/StubTeamResourceService.cs index ba189bc87..55b92214c 100644 --- a/src/Humans.Infrastructure/Services/StubTeamResourceService.cs +++ b/src/Humans.Infrastructure/Services/StubTeamResourceService.cs @@ -1,3 +1,5 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using NodaTime; @@ -18,22 +20,24 @@ public class StubTeamResourceService : ITeamResourceService { private readonly HumansDbContext _dbContext; private readonly TeamResourceManagementSettings _resourceSettings; - private readonly ITeamService _teamService; + private readonly IServiceProvider _serviceProvider; private readonly IRoleAssignmentService _roleAssignmentService; private readonly IClock _clock; private readonly ILogger _logger; + private ITeamService TeamService => _serviceProvider.GetRequiredService(); + public StubTeamResourceService( HumansDbContext dbContext, IOptions resourceSettings, - ITeamService teamService, + IServiceProvider serviceProvider, IRoleAssignmentService roleAssignmentService, IClock clock, ILogger logger) { _dbContext = dbContext; _resourceSettings = resourceSettings.Value; - _teamService = teamService; + _serviceProvider = serviceProvider; _roleAssignmentService = roleAssignmentService; _clock = clock; _logger = logger; @@ -42,7 +46,137 @@ public StubTeamResourceService( /// public async Task> GetTeamResourcesAsync(Guid teamId, CancellationToken ct = default) { - return await TeamResourcePersistence.GetActiveTeamResourcesAsync(_dbContext, teamId, ct); + return await _dbContext.GoogleResources + .AsNoTracking() + .Where(r => r.TeamId == teamId && r.IsActive) + .OrderBy(r => r.ProvisionedAt) + .ToListAsync(ct); + } + + /// + public async Task>> GetResourcesByTeamIdsAsync( + IReadOnlyCollection teamIds, + CancellationToken ct = default) + { + if (teamIds.Count == 0) + { + return new Dictionary>(); + } + + var rows = await _dbContext.GoogleResources + .AsNoTracking() + .Where(r => teamIds.Contains(r.TeamId) && r.IsActive) + .OrderBy(r => r.ProvisionedAt) + .ToListAsync(ct); + + var result = new Dictionary>(teamIds.Count); + foreach (var teamId in teamIds) + { + result[teamId] = Array.Empty(); + } + foreach (var group in rows.GroupBy(r => r.TeamId)) + { + result[group.Key] = group.ToList(); + } + return result; + } + + /// + public async Task> GetTeamResourceSummariesAsync( + IReadOnlyCollection teamIds, + CancellationToken ct = default) + { + if (teamIds.Count == 0) + { + return new Dictionary(); + } + + var rows = await _dbContext.GoogleResources + .AsNoTracking() + .Where(r => teamIds.Contains(r.TeamId) && r.IsActive) + .Select(r => new { r.TeamId, r.ResourceType }) + .ToListAsync(ct); + + var result = new Dictionary(teamIds.Count); + foreach (var teamId in teamIds) + { + result[teamId] = TeamResourceSummary.Empty; + } + foreach (var group in rows.GroupBy(r => r.TeamId)) + { + var hasMailGroup = group.Any(r => r.ResourceType == GoogleResourceType.Group); + var driveCount = group.Count(r => r.ResourceType != GoogleResourceType.Group); + result[group.Key] = new TeamResourceSummary(hasMailGroup, driveCount); + } + return result; + } + + /// + public async Task> GetActiveResourceCountsByTeamAsync(CancellationToken ct = default) + { + return await _dbContext.GoogleResources + .AsNoTracking() + .Where(r => r.IsActive) + .GroupBy(r => r.TeamId) + .Select(g => new { TeamId = g.Key, Count = g.Count() }) + .ToDictionaryAsync(x => x.TeamId, x => x.Count, ct); + } + + /// + public async Task> GetUserTeamResourcesAsync( + Guid userId, + CancellationToken ct = default) + { + var memberships = await TeamService.GetUserTeamsAsync(userId, ct); + if (memberships.Count == 0) + { + return Array.Empty(); + } + + var teamById = memberships + .Select(tm => tm.Team) + .Where(t => t is not null) + .GroupBy(t => t!.Id) + .ToDictionary(g => g.Key, g => g.First()!); + + var teamIds = teamById.Keys.ToList(); + + var resources = await _dbContext.GoogleResources + .AsNoTracking() + .Where(r => teamIds.Contains(r.TeamId) && r.IsActive) + .ToListAsync(ct); + + return resources + .Select(r => new + { + Team = teamById.TryGetValue(r.TeamId, out var t) ? t : null, + Resource = r + }) + .Where(x => x.Team is not null) + .OrderBy(x => x.Team!.Name, StringComparer.OrdinalIgnoreCase) + .ThenBy(x => x.Resource.Name, StringComparer.OrdinalIgnoreCase) + .Select(x => new UserTeamGoogleResource( + x.Team!.Name, + x.Team.Slug, + x.Resource.Name, + x.Resource.ResourceType, + x.Resource.Url)) + .ToList(); + } + + /// + public async Task> GetActiveDriveFoldersAsync(CancellationToken ct = default) + { + return await _dbContext.GoogleResources + .AsNoTracking() + .Where(r => r.IsActive && r.ResourceType == GoogleResourceType.DriveFolder) + .ToListAsync(ct); + } + + /// + public async Task GetResourceCountAsync(CancellationToken ct = default) + { + return await _dbContext.GoogleResources.CountAsync(ct); } /// @@ -158,12 +292,15 @@ public Task LinkGroupAsync(Guid teamId, string groupEmail, C /// public async Task UnlinkResourceAsync(Guid resourceId, CancellationToken ct = default) { - var resource = await TeamResourcePersistence.DeactivateResourceAsync(_dbContext, resourceId, ct); + var resource = await _dbContext.GoogleResources + .FirstOrDefaultAsync(r => r.Id == resourceId, ct); if (resource is null) { return; } + resource.IsActive = false; + await _dbContext.SaveChangesAsync(ct); _logger.LogInformation("[STUB] Unlinked resource {ResourceId}", resourceId); } @@ -171,7 +308,7 @@ public async Task UnlinkResourceAsync(Guid resourceId, CancellationToken ct = de public async Task CanManageTeamResourcesAsync(Guid teamId, Guid userId, CancellationToken ct = default) { return await TeamResourceAccessRules.CanManageTeamResourcesAsync( - _teamService, + TeamService, _roleAssignmentService, _resourceSettings, teamId, @@ -188,7 +325,10 @@ public Task GetServiceAccountEmailAsync(CancellationToken ct = default) /// public async Task GetResourceByIdAsync(Guid resourceId, CancellationToken ct = default) { - return await TeamResourcePersistence.GetResourceByIdAsync(_dbContext, resourceId, ct); + return await _dbContext.GoogleResources + .AsNoTracking() + .Include(r => r.Team) + .FirstOrDefaultAsync(r => r.Id == resourceId, ct); } /// diff --git a/src/Humans.Infrastructure/Services/TeamResourcePersistence.cs b/src/Humans.Infrastructure/Services/TeamResourcePersistence.cs deleted file mode 100644 index 1875c398a..000000000 --- a/src/Humans.Infrastructure/Services/TeamResourcePersistence.cs +++ /dev/null @@ -1,49 +0,0 @@ -using Humans.Domain.Entities; -using Humans.Infrastructure.Data; -using Microsoft.EntityFrameworkCore; - -namespace Humans.Infrastructure.Services; - -internal static class TeamResourcePersistence -{ - public static async Task> GetActiveTeamResourcesAsync( - HumansDbContext dbContext, - Guid teamId, - CancellationToken ct = default) - { - return await dbContext.GoogleResources - .Where(r => r.TeamId == teamId && r.IsActive) - .OrderBy(r => r.ProvisionedAt) - .AsNoTracking() - .ToListAsync(ct); - } - - public static async Task DeactivateResourceAsync( - HumansDbContext dbContext, - Guid resourceId, - CancellationToken ct = default) - { - var resource = await dbContext.GoogleResources - .FirstOrDefaultAsync(r => r.Id == resourceId, ct); - - if (resource is null) - { - return null; - } - - resource.IsActive = false; - await dbContext.SaveChangesAsync(ct); - return resource; - } - - public static async Task GetResourceByIdAsync( - HumansDbContext dbContext, - Guid resourceId, - CancellationToken ct = default) - { - return await dbContext.GoogleResources - .AsNoTracking() - .Include(r => r.Team) - .FirstOrDefaultAsync(r => r.Id == resourceId, ct); - } -} diff --git a/src/Humans.Infrastructure/Services/TeamResourceService.cs b/src/Humans.Infrastructure/Services/TeamResourceService.cs index a7661ac54..af8ed9972 100644 --- a/src/Humans.Infrastructure/Services/TeamResourceService.cs +++ b/src/Humans.Infrastructure/Services/TeamResourceService.cs @@ -5,6 +5,7 @@ using Google.Apis.Drive.v3; using Google.Apis.Services; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using NodaTime; @@ -26,7 +27,7 @@ public partial class TeamResourceService : ITeamResourceService private readonly HumansDbContext _dbContext; private readonly GoogleWorkspaceSettings _googleSettings; private readonly TeamResourceManagementSettings _resourceSettings; - private readonly ITeamService _teamService; + private readonly IServiceProvider _serviceProvider; private readonly IRoleAssignmentService _roleAssignmentService; private readonly IGoogleSyncService _googleSyncService; private readonly IClock _clock; @@ -36,11 +37,15 @@ public partial class TeamResourceService : ITeamResourceService private CloudIdentityService? _cloudIdentityService; private string? _serviceAccountEmail; + // Lazy to break the DI cycle: ITeamService depends on ITeamResourceService for reads, + // and ITeamResourceService depends on ITeamService for team-membership lookups. + private ITeamService TeamService => _serviceProvider.GetRequiredService(); + public TeamResourceService( HumansDbContext dbContext, IOptions googleSettings, IOptions resourceSettings, - ITeamService teamService, + IServiceProvider serviceProvider, IRoleAssignmentService roleAssignmentService, IGoogleSyncService googleSyncService, IClock clock, @@ -49,7 +54,7 @@ public TeamResourceService( _dbContext = dbContext; _googleSettings = googleSettings.Value; _resourceSettings = resourceSettings.Value; - _teamService = teamService; + _serviceProvider = serviceProvider; _roleAssignmentService = roleAssignmentService; _googleSyncService = googleSyncService; _clock = clock; @@ -59,7 +64,137 @@ public TeamResourceService( /// public async Task> GetTeamResourcesAsync(Guid teamId, CancellationToken ct = default) { - return await TeamResourcePersistence.GetActiveTeamResourcesAsync(_dbContext, teamId, ct); + return await _dbContext.GoogleResources + .AsNoTracking() + .Where(r => r.TeamId == teamId && r.IsActive) + .OrderBy(r => r.ProvisionedAt) + .ToListAsync(ct); + } + + /// + public async Task>> GetResourcesByTeamIdsAsync( + IReadOnlyCollection teamIds, + CancellationToken ct = default) + { + if (teamIds.Count == 0) + { + return new Dictionary>(); + } + + var rows = await _dbContext.GoogleResources + .AsNoTracking() + .Where(r => teamIds.Contains(r.TeamId) && r.IsActive) + .OrderBy(r => r.ProvisionedAt) + .ToListAsync(ct); + + var result = new Dictionary>(teamIds.Count); + foreach (var teamId in teamIds) + { + result[teamId] = Array.Empty(); + } + foreach (var group in rows.GroupBy(r => r.TeamId)) + { + result[group.Key] = group.ToList(); + } + return result; + } + + /// + public async Task> GetTeamResourceSummariesAsync( + IReadOnlyCollection teamIds, + CancellationToken ct = default) + { + if (teamIds.Count == 0) + { + return new Dictionary(); + } + + var rows = await _dbContext.GoogleResources + .AsNoTracking() + .Where(r => teamIds.Contains(r.TeamId) && r.IsActive) + .Select(r => new { r.TeamId, r.ResourceType }) + .ToListAsync(ct); + + var result = new Dictionary(teamIds.Count); + foreach (var teamId in teamIds) + { + result[teamId] = TeamResourceSummary.Empty; + } + foreach (var group in rows.GroupBy(r => r.TeamId)) + { + var hasMailGroup = group.Any(r => r.ResourceType == GoogleResourceType.Group); + var driveCount = group.Count(r => r.ResourceType != GoogleResourceType.Group); + result[group.Key] = new TeamResourceSummary(hasMailGroup, driveCount); + } + return result; + } + + /// + public async Task> GetActiveResourceCountsByTeamAsync(CancellationToken ct = default) + { + return await _dbContext.GoogleResources + .AsNoTracking() + .Where(r => r.IsActive) + .GroupBy(r => r.TeamId) + .Select(g => new { TeamId = g.Key, Count = g.Count() }) + .ToDictionaryAsync(x => x.TeamId, x => x.Count, ct); + } + + /// + public async Task> GetUserTeamResourcesAsync( + Guid userId, + CancellationToken ct = default) + { + var memberships = await TeamService.GetUserTeamsAsync(userId, ct); + if (memberships.Count == 0) + { + return Array.Empty(); + } + + var teamById = memberships + .Select(tm => tm.Team) + .Where(t => t is not null) + .GroupBy(t => t!.Id) + .ToDictionary(g => g.Key, g => g.First()!); + + var teamIds = teamById.Keys.ToList(); + + var resources = await _dbContext.GoogleResources + .AsNoTracking() + .Where(r => teamIds.Contains(r.TeamId) && r.IsActive) + .ToListAsync(ct); + + return resources + .Select(r => new + { + Team = teamById.TryGetValue(r.TeamId, out var t) ? t : null, + Resource = r + }) + .Where(x => x.Team is not null) + .OrderBy(x => x.Team!.Name, StringComparer.OrdinalIgnoreCase) + .ThenBy(x => x.Resource.Name, StringComparer.OrdinalIgnoreCase) + .Select(x => new UserTeamGoogleResource( + x.Team!.Name, + x.Team.Slug, + x.Resource.Name, + x.Resource.ResourceType, + x.Resource.Url)) + .ToList(); + } + + /// + public async Task> GetActiveDriveFoldersAsync(CancellationToken ct = default) + { + return await _dbContext.GoogleResources + .AsNoTracking() + .Where(r => r.IsActive && r.ResourceType == GoogleResourceType.DriveFolder) + .ToListAsync(ct); + } + + /// + public async Task GetResourceCountAsync(CancellationToken ct = default) + { + return await _dbContext.GoogleResources.CountAsync(ct); } /// @@ -383,12 +518,16 @@ public async Task LinkGroupAsync(Guid teamId, string groupEm /// public async Task UnlinkResourceAsync(Guid resourceId, CancellationToken ct = default) { - var resource = await TeamResourcePersistence.DeactivateResourceAsync(_dbContext, resourceId, ct); + var resource = await _dbContext.GoogleResources + .FirstOrDefaultAsync(r => r.Id == resourceId, ct); if (resource is null) { return; } + resource.IsActive = false; + await _dbContext.SaveChangesAsync(ct); + _logger.LogInformation("Unlinked resource {ResourceId} ({ResourceName})", resourceId, resource.Name); } @@ -396,7 +535,7 @@ public async Task UnlinkResourceAsync(Guid resourceId, CancellationToken ct = de public async Task CanManageTeamResourcesAsync(Guid teamId, Guid userId, CancellationToken ct = default) { return await TeamResourceAccessRules.CanManageTeamResourcesAsync( - _teamService, + TeamService, _roleAssignmentService, _resourceSettings, teamId, @@ -681,7 +820,10 @@ private async Task GetServiceAccountCredentialAsync(Cancellati public async Task GetResourceByIdAsync(Guid resourceId, CancellationToken ct = default) { - return await TeamResourcePersistence.GetResourceByIdAsync(_dbContext, resourceId, ct); + return await _dbContext.GoogleResources + .AsNoTracking() + .Include(r => r.Team) + .FirstOrDefaultAsync(r => r.Id == resourceId, ct); } /// diff --git a/src/Humans.Infrastructure/Services/TeamService.cs b/src/Humans.Infrastructure/Services/TeamService.cs index 2d16c8639..3eba32087 100644 --- a/src/Humans.Infrastructure/Services/TeamService.cs +++ b/src/Humans.Infrastructure/Services/TeamService.cs @@ -1,6 +1,7 @@ using System.Collections.Concurrent; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using NodaTime; using Humans.Application; @@ -26,10 +27,16 @@ public class TeamService : ITeamService private readonly IRoleAssignmentService _roleAssignmentService; private readonly IShiftManagementService _shiftManagementService; private readonly ISystemTeamSync _systemTeamSync; + private readonly IServiceProvider _serviceProvider; private readonly IClock _clock; private readonly IMemoryCache _cache; private readonly ILogger _logger; + // Lazy to break the DI cycle with ITeamResourceService, which also needs ITeamService + // for user-membership lookups when resolving resources. + private ITeamResourceService TeamResourceService + => _serviceProvider.GetRequiredService(); + public TeamService( HumansDbContext dbContext, IAuditLogService auditLogService, @@ -38,6 +45,7 @@ public TeamService( IRoleAssignmentService roleAssignmentService, IShiftManagementService shiftManagementService, ISystemTeamSync systemTeamSync, + IServiceProvider serviceProvider, IClock clock, IMemoryCache cache, ILogger logger) @@ -49,6 +57,7 @@ public TeamService( _roleAssignmentService = roleAssignmentService; _shiftManagementService = shiftManagementService; _systemTeamSync = systemTeamSync; + _serviceProvider = serviceProvider; _clock = clock; _cache = cache; _logger = logger; @@ -362,22 +371,6 @@ public async Task> GetUserTeamsAsync(Guid userId, Canc .ToListAsync(cancellationToken); } - /// - public async Task> GetUserTeamGoogleResourcesAsync(Guid userId, CancellationToken cancellationToken = default) - { - var teamResources = await ( - from tm in _dbContext.TeamMembers.AsNoTracking() - where tm.UserId == userId && tm.LeftAt == null - join t in _dbContext.Teams on tm.TeamId equals t.Id - join r in _dbContext.GoogleResources on t.Id equals r.TeamId - where r.IsActive - orderby t.Name, r.Name - select new UserTeamGoogleResource(t.Name, t.Slug, r.Name, r.ResourceType, r.Url) - ).ToListAsync(cancellationToken); - - return teamResources; - } - public async Task> GetMyTeamMembershipsAsync( Guid userId, CancellationToken cancellationToken = default) @@ -1903,11 +1896,7 @@ private async Task SendAddedToTeamEmailAsync(Guid userId, Team team, Cancellatio if (user is null) return; var email = user.GetEffectiveEmail() ?? user.Email!; - var resources = await _dbContext.GoogleResources - .AsNoTracking() - .Where(gr => gr.TeamId == team.Id && gr.IsActive) - .Select(gr => new { gr.Name, gr.Url }) - .ToListAsync(cancellationToken); + var resources = await TeamResourceService.GetTeamResourcesAsync(team.Id, cancellationToken); await _emailService.SendAddedToTeamAsync( email, user.DisplayName, team.Name, team.Slug, @@ -2052,7 +2041,6 @@ public async Task>> GetNonSystemTeamNames var query = _dbContext.Teams .Include(t => t.Members.Where(m => m.LeftAt == null)) .Include(t => t.JoinRequests.Where(r => r.Status == TeamJoinRequestStatus.Pending)) - .Include(t => t.GoogleResources) .Include(t => t.RoleDefinitions) .OrderBy(t => t.SystemTeamType) .ThenBy(t => t.Name); @@ -2085,7 +2073,13 @@ public async Task GetAdminTeamListAsync( : await _shiftManagementService.GetPendingShiftSignupCountsByTeamAsync( activeEventId, cancellationToken); - return new AdminTeamListResult(BuildAdminTeamSummaries(items, pendingShiftCounts), totalCount); + var teamIds = items.Select(t => t.Id).ToList(); + var resourceSummaries = await TeamResourceService + .GetTeamResourceSummariesAsync(teamIds, cancellationToken); + + return new AdminTeamListResult( + BuildAdminTeamSummaries(items, pendingShiftCounts, resourceSummaries), + totalCount); } // ========================================================================== @@ -2182,7 +2176,8 @@ private async Task> GetCachedTeamsAsync(C private static IReadOnlyList BuildAdminTeamSummaries( IReadOnlyList teams, - IReadOnlyDictionary pendingShiftCounts) + IReadOnlyDictionary pendingShiftCounts, + IReadOnlyDictionary resourceSummaries) { var ordered = new List(teams.Count); @@ -2193,30 +2188,31 @@ private static IReadOnlyList BuildAdminTeamSummaries( continue; } - ordered.Add(CreateAdminTeamSummary(team, isChildTeam: false, pendingShiftCounts)); + ordered.Add(CreateAdminTeamSummary(team, isChildTeam: false, pendingShiftCounts, resourceSummaries)); var children = teams .Where(child => child.ParentTeamId == team.Id) .OrderBy(child => child.Name, StringComparer.OrdinalIgnoreCase); - ordered.AddRange(children.Select(child => CreateAdminTeamSummary(child, isChildTeam: true, pendingShiftCounts))); + ordered.AddRange(children.Select(child => + CreateAdminTeamSummary(child, isChildTeam: true, pendingShiftCounts, resourceSummaries))); } return ordered; } private static AdminTeamSummary CreateAdminTeamSummary( - Team team, bool isChildTeam, IReadOnlyDictionary pendingShiftCounts) + Team team, + bool isChildTeam, + IReadOnlyDictionary pendingShiftCounts, + IReadOnlyDictionary resourceSummaries) { var systemTeamType = team.SystemTeamType != SystemTeamType.None ? team.SystemTeamType.ToString() : null; - var hasMailGroup = team.GoogleResources.Any(resource => - resource.ResourceType == GoogleResourceType.Group && - resource.IsActive); - var driveResourceCount = team.GoogleResources.Count(resource => - resource.ResourceType != GoogleResourceType.Group && - resource.IsActive); + var resourceSummary = resourceSummaries.TryGetValue(team.Id, out var summary) + ? summary + : TeamResourceSummary.Empty; return new AdminTeamSummary( team.Id, @@ -2228,9 +2224,9 @@ private static AdminTeamSummary CreateAdminTeamSummary( systemTeamType, team.Members.Count, team.JoinRequests.Count, - hasMailGroup, + resourceSummary.HasMailGroup, team.GoogleGroupEmail, - driveResourceCount, + resourceSummary.DriveResourceCount, team.RoleDefinitions.Sum(role => role.SlotCount), team.CreatedAt, isChildTeam, diff --git a/src/Humans.Web/ViewComponents/MyGoogleResourcesViewComponent.cs b/src/Humans.Web/ViewComponents/MyGoogleResourcesViewComponent.cs index bf6820dc1..258fa273c 100644 --- a/src/Humans.Web/ViewComponents/MyGoogleResourcesViewComponent.cs +++ b/src/Humans.Web/ViewComponents/MyGoogleResourcesViewComponent.cs @@ -8,16 +8,16 @@ namespace Humans.Web.ViewComponents; public class MyGoogleResourcesViewComponent : ViewComponent { - private readonly ITeamService _teamService; + private readonly ITeamResourceService _teamResourceService; private readonly UserManager _userManager; private readonly ILogger _logger; public MyGoogleResourcesViewComponent( - ITeamService teamService, + ITeamResourceService teamResourceService, UserManager userManager, ILogger logger) { - _teamService = teamService; + _teamResourceService = teamResourceService; _userManager = userManager; _logger = logger; } @@ -30,7 +30,7 @@ public async Task InvokeAsync() if (user is null) return Content(string.Empty); - var resources = await _teamService.GetUserTeamGoogleResourcesAsync(user.Id); + var resources = await _teamResourceService.GetUserTeamResourcesAsync(user.Id); if (resources.Count == 0) return Content(string.Empty); diff --git a/tests/Humans.Application.Tests/Services/GoogleAdminServiceTests.cs b/tests/Humans.Application.Tests/Services/GoogleAdminServiceTests.cs index f33f7aabd..2bf98af76 100644 --- a/tests/Humans.Application.Tests/Services/GoogleAdminServiceTests.cs +++ b/tests/Humans.Application.Tests/Services/GoogleAdminServiceTests.cs @@ -36,11 +36,15 @@ public GoogleAdminServiceTests() _googleSyncService = Substitute.For(); _userEmailService = Substitute.For(); _auditLogService = Substitute.For(); + var teamResourceService = Substitute.For(); + teamResourceService.GetActiveResourceCountsByTeamAsync(Arg.Any()) + .Returns(new Dictionary()); _service = new GoogleAdminService( _dbContext, _workspaceUserService, _googleSyncService, + teamResourceService, _userEmailService, _auditLogService, new FakeClock(Instant.FromUtc(2026, 1, 1, 0, 0)), diff --git a/tests/Humans.Application.Tests/Services/TeamRoleServiceTests.cs b/tests/Humans.Application.Tests/Services/TeamRoleServiceTests.cs index 0cb85d220..db54148d4 100644 --- a/tests/Humans.Application.Tests/Services/TeamRoleServiceTests.cs +++ b/tests/Humans.Application.Tests/Services/TeamRoleServiceTests.cs @@ -41,6 +41,11 @@ public TeamRoleServiceTests() NullLogger.Instance); var serviceProvider = Substitute.For(); serviceProvider.GetService(typeof(ITeamService)).Returns(Substitute.For()); + var teamResourceService = Substitute.For(); + teamResourceService + .GetTeamResourceSummariesAsync(Arg.Any>(), Arg.Any()) + .Returns(new Dictionary()); + serviceProvider.GetService(typeof(ITeamResourceService)).Returns(teamResourceService); var shiftManagementService = new ShiftManagementService( _dbContext, Substitute.For(), @@ -57,6 +62,7 @@ public TeamRoleServiceTests() roleAssignmentService, shiftManagementService, Substitute.For(), + serviceProvider, _clock, cache, NullLogger.Instance); diff --git a/tests/Humans.Application.Tests/Services/TeamServiceTests.cs b/tests/Humans.Application.Tests/Services/TeamServiceTests.cs index f45b535fc..7b01c64c8 100644 --- a/tests/Humans.Application.Tests/Services/TeamServiceTests.cs +++ b/tests/Humans.Application.Tests/Services/TeamServiceTests.cs @@ -44,6 +44,11 @@ public TeamServiceTests() NullLogger.Instance); var serviceProvider = Substitute.For(); serviceProvider.GetService(typeof(ITeamService)).Returns(Substitute.For()); + var teamResourceService = Substitute.For(); + teamResourceService + .GetTeamResourceSummariesAsync(Arg.Any>(), Arg.Any()) + .Returns(new Dictionary()); + serviceProvider.GetService(typeof(ITeamResourceService)).Returns(teamResourceService); var shiftManagementService = new ShiftManagementService( _dbContext, Substitute.For(), @@ -60,6 +65,7 @@ public TeamServiceTests() _roleAssignmentService, shiftManagementService, Substitute.For(), + serviceProvider, _clock, _cache, NullLogger.Instance); From 8ecf6b115dfc574d3826a48ec4b7530029507142 Mon Sep 17 00:00:00 2001 From: Peter Drier Date: Tue, 14 Apr 2026 23:54:26 +0200 Subject: [PATCH 2/2] Address PR #225 review findings 1-4 - Change google_resources FK from SET NULL to RESTRICT. TeamId is non-nullable, so the previous SET NULL would have thrown on team delete. Adds migration RestrictGoogleResourceTeamDelete (db:yes, was previously db:no). - Rewrite GetUserTeamResourcesAsync in both TeamResourceService and StubTeamResourceService as a single DB-sorted JOIN. Restores pre-PR ordering (DB collation, not OrdinalIgnoreCase), removes the silent null-Team filter, and eliminates the service-locator hop through ITeamService for this path. - Add AsNoTracking to GetResourceCountAsync for consistency with sibling reads. Phase 2 decomposition of GoogleWorkspaceSyncService writes is tracked as nobodies-collective/Humans#492. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Data/Configurations/TeamConfiguration.cs | 5 +- ...strictGoogleResourceTeamDelete.Designer.cs | 4618 +++++++++++++++++ ...215135_RestrictGoogleResourceTeamDelete.cs | 42 + .../HumansDbContextModelSnapshot.cs | 2 +- .../Services/StubTeamResourceService.cs | 46 +- .../Services/TeamResourceService.cs | 46 +- 6 files changed, 4685 insertions(+), 74 deletions(-) create mode 100644 src/Humans.Infrastructure/Migrations/20260414215135_RestrictGoogleResourceTeamDelete.Designer.cs create mode 100644 src/Humans.Infrastructure/Migrations/20260414215135_RestrictGoogleResourceTeamDelete.cs diff --git a/src/Humans.Infrastructure/Data/Configurations/TeamConfiguration.cs b/src/Humans.Infrastructure/Data/Configurations/TeamConfiguration.cs index d926df12f..b400e5fc9 100644 --- a/src/Humans.Infrastructure/Data/Configurations/TeamConfiguration.cs +++ b/src/Humans.Infrastructure/Data/Configurations/TeamConfiguration.cs @@ -110,10 +110,13 @@ public void Configure(EntityTypeBuilder builder) // google_resources is owned by TeamResourceService. The Team → GoogleResource // navigation was removed to enforce ownership — the relationship is now // configured from the GoogleResource side only via its Team nav property. + // Restrict (not SetNull): GoogleResource.TeamId is non-nullable, so SetNull + // would produce a NOT NULL violation on team delete. Teams should never be + // hard-deleted if resources exist — the caller must unlink resources first. builder.HasMany() .WithOne(gr => gr.Team) .HasForeignKey(gr => gr.TeamId) - .OnDelete(DeleteBehavior.SetNull); + .OnDelete(DeleteBehavior.Restrict); builder.HasIndex(t => t.Slug) .IsUnique(); diff --git a/src/Humans.Infrastructure/Migrations/20260414215135_RestrictGoogleResourceTeamDelete.Designer.cs b/src/Humans.Infrastructure/Migrations/20260414215135_RestrictGoogleResourceTeamDelete.Designer.cs new file mode 100644 index 000000000..05d62bf07 --- /dev/null +++ b/src/Humans.Infrastructure/Migrations/20260414215135_RestrictGoogleResourceTeamDelete.Designer.cs @@ -0,0 +1,4618 @@ +// +using System; +using Humans.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NodaTime; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Humans.Infrastructure.Migrations +{ + [DbContext(typeof(HumansDbContext))] + [Migration("20260414215135_RestrictGoogleResourceTeamDelete")] + partial class RestrictGoogleResourceTeamDelete + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Humans.Domain.Entities.AccountMergeRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AdminNotes") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PendingEmailId") + .HasColumnType("uuid"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ResolvedByUserId") + .HasColumnType("uuid"); + + b.Property("SourceUserId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ResolvedByUserId"); + + b.HasIndex("SourceUserId"); + + b.HasIndex("Status"); + + b.HasIndex("TargetUserId"); + + b.ToTable("account_merge_requests", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.Application", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AdditionalInfo") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("BoardMeetingDate") + .HasColumnType("date"); + + b.Property("DecisionNote") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Language") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("MembershipTier") + .IsRequired() + .HasColumnType("text"); + + b.Property("Motivation") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("RenewalReminderSentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewNotes") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ReviewStartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByUserId") + .HasColumnType("uuid"); + + b.Property("RoleUnderstanding") + .HasColumnType("text"); + + b.Property("SignificantContribution") + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("SubmittedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TermExpiresAt") + .HasColumnType("date"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("MembershipTier"); + + b.HasIndex("ReviewedByUserId"); + + b.HasIndex("Status"); + + b.HasIndex("SubmittedAt"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("applications", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.ApplicationStateHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ChangedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ChangedByUserId") + .HasColumnType("uuid"); + + b.Property("Notes") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("ChangedAt"); + + b.HasIndex("ChangedByUserId"); + + b.ToTable("application_state_history", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ActorUserId") + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("EntityId") + .HasColumnType("uuid"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ErrorMessage") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RelatedEntityId") + .HasColumnType("uuid"); + + b.Property("RelatedEntityType") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ResourceId") + .HasColumnType("uuid"); + + b.Property("Role") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Success") + .HasColumnType("boolean"); + + b.Property("SyncSource") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserEmail") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("ActorUserId"); + + b.HasIndex("OccurredAt"); + + b.HasIndex("ResourceId"); + + b.HasIndex("EntityType", "EntityId"); + + b.HasIndex("RelatedEntityType", "RelatedEntityId"); + + b.ToTable("audit_log", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.BoardVote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("BoardMemberUserId") + .HasColumnType("uuid"); + + b.Property("Note") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Vote") + .IsRequired() + .HasColumnType("text"); + + b.Property("VotedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("BoardMemberUserId"); + + b.HasIndex("ApplicationId", "BoardMemberUserId") + .IsUnique(); + + b.ToTable("board_votes", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.BudgetAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActorUserId") + .HasColumnType("uuid"); + + b.Property("BudgetYearId") + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("EntityId") + .HasColumnType("uuid"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FieldName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NewValue") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OldValue") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId"); + + b.HasIndex("BudgetYearId"); + + b.HasIndex("OccurredAt"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("budget_audit_logs", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.BudgetCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllocatedAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("BudgetGroupId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpenditureType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("TeamId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("TeamId") + .HasFilter("\"TeamId\" IS NOT NULL"); + + b.HasIndex("BudgetGroupId", "SortOrder"); + + b.ToTable("budget_categories", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.BudgetGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BudgetYearId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDepartmentGroup") + .HasColumnType("boolean"); + + b.Property("IsRestricted") + .HasColumnType("boolean"); + + b.Property("IsTicketingGroup") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("BudgetYearId", "SortOrder"); + + b.ToTable("budget_groups", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.BudgetLineItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("BudgetCategoryId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ExpectedDate") + .HasColumnType("date"); + + b.Property("IsAutoGenerated") + .HasColumnType("boolean"); + + b.Property("IsCashflowOnly") + .HasColumnType("boolean"); + + b.Property("Notes") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("ResponsibleTeamId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VatRate") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ResponsibleTeamId") + .HasFilter("\"ResponsibleTeamId\" IS NOT NULL"); + + b.HasIndex("BudgetCategoryId", "SortOrder"); + + b.ToTable("budget_line_items", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.BudgetYear", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Year") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("Year") + .IsUnique(); + + b.ToTable("budget_years", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.Camp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContactEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ContactPhone") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid"); + + b.Property("HideHistoricalNames") + .HasColumnType("boolean"); + + b.Property("IsSwissCamp") + .HasColumnType("boolean"); + + b.Property("Links") + .HasColumnType("jsonb"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TimesAtNowhere") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WebOrSocialUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("camps", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.CampHistoricalName", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CampId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CampId"); + + b.ToTable("camp_historical_names", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.CampImage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CampId") + .HasColumnType("uuid"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StoragePath") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("UploadedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CampId"); + + b.ToTable("camp_images", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.CampLead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CampId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LeftAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("CampId", "UserId") + .IsUnique() + .HasDatabaseName("IX_camp_leads_active_unique") + .HasFilter("\"LeftAt\" IS NULL"); + + b.ToTable("camp_leads", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.CampPolygon", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AreaSqm") + .HasColumnType("double precision"); + + b.Property("CampSeasonId") + .HasColumnType("uuid"); + + b.Property("GeoJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModifiedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastModifiedByUserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CampSeasonId") + .IsUnique(); + + b.HasIndex("LastModifiedByUserId"); + + b.ToTable("camp_polygons", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.CampPolygonHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AreaSqm") + .HasColumnType("double precision"); + + b.Property("CampSeasonId") + .HasColumnType("uuid"); + + b.Property("GeoJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("ModifiedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ModifiedByUserId") + .HasColumnType("uuid"); + + b.Property("Note") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("ModifiedByUserId"); + + b.HasIndex("CampSeasonId", "ModifiedAt"); + + b.ToTable("camp_polygon_histories", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.CampSeason", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptingMembers") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("AdultPlayspace") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BlurbLong") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("BlurbShort") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("CampId") + .HasColumnType("uuid"); + + b.Property("ContainerCount") + .HasColumnType("integer"); + + b.Property("ContainerNotes") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ElectricalGrid") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("HasPerformanceSpace") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("KidsAreaDescription") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("KidsVisiting") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("KidsWelcome") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Languages") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("MemberCount") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NameLockDate") + .HasColumnType("date"); + + b.Property("NameLockedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PerformanceTypes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewNotes") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("ReviewedByUserId") + .HasColumnType("uuid"); + + b.Property("SoundZone") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SpaceRequirement") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Vibes") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ReviewedByUserId"); + + b.HasIndex("Status"); + + b.HasIndex("CampId", "Year") + .IsUnique(); + + b.ToTable("camp_seasons", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.CampSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("OpenSeasons") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("PublicYear") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("camp_settings", (string)null); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0010-000000000001"), + OpenSeasons = "[2026]", + PublicYear = 2026 + }); + }); + + modelBuilder.Entity("Humans.Domain.Entities.Campaign", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("EmailBodyTemplate") + .IsRequired() + .HasColumnType("text"); + + b.Property("EmailSubject") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ReplyToAddress") + .HasMaxLength(320) + .HasColumnType("character varying(320)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.ToTable("campaigns", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.CampaignCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CampaignId") + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ImportOrder") + .HasColumnType("integer"); + + b.Property("ImportedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId", "Code") + .IsUnique(); + + b.ToTable("campaign_codes", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.CampaignGrant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CampaignCodeId") + .HasColumnType("uuid"); + + b.Property("CampaignId") + .HasColumnType("uuid"); + + b.Property("LatestEmailAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LatestEmailStatus") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("RedeemedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CampaignCodeId") + .IsUnique(); + + b.HasIndex("UserId"); + + b.HasIndex("CampaignId", "UserId") + .IsUnique(); + + b.ToTable("campaign_grants", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.CityPlanningSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClosedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsPlacementOpen") + .HasColumnType("boolean"); + + b.Property("LimitZoneGeoJson") + .HasColumnType("text"); + + b.Property("OfficialZonesGeoJson") + .HasColumnType("text"); + + b.Property("OpenedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlacementClosesAt") + .HasColumnType("timestamp without time zone"); + + b.Property("PlacementOpensAt") + .HasColumnType("timestamp without time zone"); + + b.Property("RegistrationInfo") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Year") + .IsUnique(); + + b.ToTable("city_planning_settings", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.CommunicationPreference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("InboxEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("OptedOut") + .HasColumnType("boolean"); + + b.Property("UpdateSource") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "Category") + .IsUnique(); + + b.ToTable("communication_preferences", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.ConsentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsentedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ContentHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("DocumentVersionId") + .HasColumnType("uuid"); + + b.Property("ExplicitConsent") + .HasColumnType("boolean"); + + b.Property("IpAddress") + .IsRequired() + .HasMaxLength(45) + .HasColumnType("character varying(45)"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ConsentedAt"); + + b.HasIndex("DocumentVersionId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "DocumentVersionId") + .IsUnique(); + + b.HasIndex("UserId", "ExplicitConsent", "ConsentedAt"); + + b.ToTable("consent_records", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.ContactField", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomLabel") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("FieldType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Visibility") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("ProfileId", "Visibility"); + + b.ToTable("contact_fields", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.DocumentVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChangesSummary") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("CommitSha") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Content") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EffectiveFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("LegalDocumentId") + .HasColumnType("uuid"); + + b.Property("RequiresReConsent") + .HasColumnType("boolean"); + + b.Property("VersionNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("CommitSha"); + + b.HasIndex("EffectiveFrom"); + + b.HasIndex("LegalDocumentId"); + + b.ToTable("document_versions", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.EmailOutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CampaignGrantId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExtraHeaders") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("HtmlBody") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastError") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("NextRetryAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PickedUpAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlainTextBody") + .HasColumnType("text"); + + b.Property("RecipientEmail") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)"); + + b.Property("RecipientName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ReplyTo") + .HasMaxLength(320) + .HasColumnType("character varying(320)"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ShiftSignupId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("TemplateName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CampaignGrantId"); + + b.HasIndex("UserId"); + + b.HasIndex("ShiftSignupId", "TemplateName") + .HasFilter("\"ShiftSignupId\" IS NOT NULL"); + + b.HasIndex("SentAt", "RetryCount", "NextRetryAt", "PickedUpAt"); + + b.ToTable("email_outbox_messages", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.EventParticipation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DeclaredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Year") + .IsUnique(); + + b.ToTable("event_participations", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.EventSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BarriosEarlyEntryAllocation") + .HasColumnType("jsonb"); + + b.Property("BuildStartOffset") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EarlyEntryCapacity") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("EarlyEntryClose") + .HasColumnType("timestamp with time zone"); + + b.Property("EventEndOffset") + .HasColumnType("integer"); + + b.Property("EventName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("GateOpeningDate") + .HasColumnType("date"); + + b.Property("GlobalVolunteerCap") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsShiftBrowsingOpen") + .HasColumnType("boolean"); + + b.Property("ReminderLeadTimeHours") + .HasColumnType("integer"); + + b.Property("StrikeEndOffset") + .HasColumnType("integer"); + + b.Property("TimeZoneId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("IsActive"); + + b.ToTable("event_settings", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.FeedbackMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(5000) + .HasColumnType("character varying(5000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FeedbackReportId") + .HasColumnType("uuid"); + + b.Property("SenderUserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("FeedbackReportId"); + + b.HasIndex("SenderUserId"); + + b.ToTable("feedback_messages", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.FeedbackReport", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AdditionalContext") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("AssignedToTeamId") + .HasColumnType("uuid"); + + b.Property("AssignedToUserId") + .HasColumnType("uuid"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(5000) + .HasColumnType("character varying(5000)"); + + b.Property("GitHubIssueNumber") + .HasColumnType("integer"); + + b.Property("LastAdminMessageAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastReporterMessageAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PageUrl") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ResolvedByUserId") + .HasColumnType("uuid"); + + b.Property("ScreenshotContentType") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ScreenshotFileName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ScreenshotStoragePath") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserAgent") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AssignedToTeamId"); + + b.HasIndex("AssignedToUserId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("ResolvedByUserId"); + + b.HasIndex("Status"); + + b.HasIndex("UserId"); + + b.ToTable("feedback_reports", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.GeneralAvailability", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.PrimitiveCollection("AvailableDayOffsets") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EventSettingsId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("EventSettingsId"); + + b.HasIndex("UserId", "EventSettingsId") + .IsUnique(); + + b.ToTable("general_availability", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.GoogleResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DrivePermissionLevel") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("GoogleId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastSyncedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ProvisionedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ResourceType") + .IsRequired() + .HasColumnType("text"); + + b.Property("RestrictInheritedAccess") + .HasColumnType("boolean"); + + b.Property("TeamId") + .HasColumnType("uuid"); + + b.Property("Url") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.HasKey("Id"); + + b.HasIndex("GoogleId"); + + b.HasIndex("IsActive"); + + b.HasIndex("TeamId"); + + b.HasIndex("TeamId", "GoogleId") + .IsUnique() + .HasFilter("\"IsActive\" = true"); + + b.ToTable("google_resources", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.GoogleSyncOutboxEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DeduplicationKey") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FailedPermanently") + .HasColumnType("boolean"); + + b.Property("LastError") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("TeamId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DeduplicationKey") + .IsUnique(); + + b.HasIndex("UserId"); + + b.HasIndex("ProcessedAt", "OccurredAt"); + + b.HasIndex("TeamId", "UserId", "ProcessedAt"); + + b.ToTable("google_sync_outbox", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.LegalDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentCommitSha") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("GitHubFolderPath") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("GracePeriodDays") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(7); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsRequired") + .HasColumnType("boolean"); + + b.Property("LastSyncedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TeamId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("IsActive"); + + b.HasIndex("TeamId", "IsActive"); + + b.ToTable("legal_documents", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionLabel") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ActionUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Body") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Class") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Priority") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ResolvedByUserId") + .HasColumnType("uuid"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("TargetGroupName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("ResolvedByUserId"); + + b.ToTable("notifications", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.NotificationRecipient", b => + { + b.Property("NotificationId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("ReadAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("NotificationId", "UserId"); + + b.HasIndex("UserId") + .HasDatabaseName("IX_NotificationRecipient_UserId"); + + b.ToTable("notification_recipients", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.Profile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AdminNotes") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Bio") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("BoardNotes") + .HasColumnType("text"); + + b.Property("BurnerName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("City") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConsentCheckAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsentCheckNotes") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ConsentCheckStatus") + .HasColumnType("text"); + + b.Property("ConsentCheckedByUserId") + .HasColumnType("uuid"); + + b.Property("ContributionInterests") + .HasColumnType("text"); + + b.Property("CountryCode") + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DateOfBirth") + .HasColumnType("date"); + + b.Property("EmergencyContactName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmergencyContactPhone") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("EmergencyContactRelationship") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsApproved") + .HasColumnType("boolean"); + + b.Property("IsSuspended") + .HasColumnType("boolean"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Latitude") + .HasColumnType("double precision"); + + b.Property("Longitude") + .HasColumnType("double precision"); + + b.Property("MembershipTier") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("Volunteer"); + + b.Property("NoPriorBurnExperience") + .HasColumnType("boolean"); + + b.Property("PlaceId") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ProfilePictureContentType") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ProfilePictureData") + .HasColumnType("bytea"); + + b.Property("Pronouns") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RejectedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RejectedByUserId") + .HasColumnType("uuid"); + + b.Property("RejectionReason") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ConsentCheckStatus"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("profiles", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.ProfileLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("LanguageCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Proficiency") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.ToTable("profile_languages", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.RoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid"); + + b.Property("Notes") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("RoleName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidTo") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("RoleName"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "RoleName") + .HasFilter("\"ValidTo\" IS NULL"); + + b.HasIndex("UserId", "RoleName", "ValidFrom"); + + b.ToTable("role_assignments", null, t => + { + t.HasCheckConstraint("CK_role_assignments_valid_window", "\"ValidTo\" IS NULL OR \"ValidTo\" > \"ValidFrom\""); + }); + }); + + modelBuilder.Entity("Humans.Domain.Entities.Rota", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("EventSettingsId") + .HasColumnType("uuid"); + + b.Property("IsVisibleToVolunteers") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Period") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Policy") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PracticalInfo") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Priority") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("TeamId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("TeamId"); + + b.HasIndex("EventSettingsId", "TeamId"); + + b.ToTable("rotas", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.Shift", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AdminOnly") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DayOffset") + .HasColumnType("integer"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Duration") + .HasColumnType("bigint"); + + b.Property("IsAllDay") + .HasColumnType("boolean"); + + b.Property("MaxVolunteers") + .HasColumnType("integer"); + + b.Property("MinVolunteers") + .HasColumnType("integer"); + + b.Property("RotaId") + .HasColumnType("uuid"); + + b.Property("StartTime") + .HasColumnType("time"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("RotaId"); + + b.ToTable("shifts", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.ShiftSignup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Enrolled") + .HasColumnType("boolean"); + + b.Property("EnrolledByUserId") + .HasColumnType("uuid"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedByUserId") + .HasColumnType("uuid"); + + b.Property("ShiftId") + .HasColumnType("uuid"); + + b.Property("SignupBlockId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("StatusReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("EnrolledByUserId"); + + b.HasIndex("ReviewedByUserId"); + + b.HasIndex("ShiftId"); + + b.HasIndex("SignupBlockId"); + + b.HasIndex("UserId"); + + b.HasIndex("ShiftId", "Status"); + + b.ToTable("shift_signups", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.ShiftTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("IX_shift_tags_name_unique"); + + b.ToTable("shift_tags", (string)null); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0003-000000000001"), + Name = "Heavy lifting" + }, + new + { + Id = new Guid("00000000-0000-0000-0003-000000000002"), + Name = "Working in the sun" + }, + new + { + Id = new Guid("00000000-0000-0000-0003-000000000003"), + Name = "Working in the shade" + }, + new + { + Id = new Guid("00000000-0000-0000-0003-000000000004"), + Name = "Organisational task" + }, + new + { + Id = new Guid("00000000-0000-0000-0003-000000000005"), + Name = "Meeting new people" + }, + new + { + Id = new Guid("00000000-0000-0000-0003-000000000006"), + Name = "Looking after folks" + }, + new + { + Id = new Guid("00000000-0000-0000-0003-000000000007"), + Name = "Exploring the site" + }, + new + { + Id = new Guid("00000000-0000-0000-0003-000000000008"), + Name = "Feeding and hydrating folks" + }); + }); + + modelBuilder.Entity("Humans.Domain.Entities.SyncServiceSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ServiceType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SyncMode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedByUserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ServiceType") + .IsUnique(); + + b.HasIndex("UpdatedByUserId"); + + b.ToTable("sync_service_settings", (string)null); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0002-000000000001"), + ServiceType = "GoogleDrive", + SyncMode = "None", + UpdatedAt = NodaTime.Instant.FromUnixTimeTicks(17730144000000000L) + }, + new + { + Id = new Guid("00000000-0000-0000-0002-000000000002"), + ServiceType = "GoogleGroups", + SyncMode = "None", + UpdatedAt = NodaTime.Instant.FromUnixTimeTicks(17730144000000000L) + }, + new + { + Id = new Guid("00000000-0000-0000-0002-000000000003"), + ServiceType = "Discord", + SyncMode = "None", + UpdatedAt = NodaTime.Instant.FromUnixTimeTicks(17730144000000000L) + }); + }); + + modelBuilder.Entity("Humans.Domain.Entities.SystemSetting", b => + { + b.Property("Key") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.HasKey("Key"); + + b.ToTable("system_settings", (string)null); + + b.HasData( + new + { + Key = "IsEmailSendingPaused", + Value = "false" + }); + }); + + modelBuilder.Entity("Humans.Domain.Entities.Team", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CallsToAction") + .HasColumnType("jsonb"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomSlug") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("GoogleGroupPrefix") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("HasBudget") + .HasColumnType("boolean"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsHidden") + .HasColumnType("boolean"); + + b.Property("IsPublicPage") + .HasColumnType("boolean"); + + b.Property("IsSensitive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PageContent") + .HasMaxLength(50000) + .HasColumnType("character varying(50000)"); + + b.Property("PageContentUpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PageContentUpdatedByUserId") + .HasColumnType("uuid"); + + b.Property("ParentTeamId") + .HasColumnType("uuid"); + + b.Property("RequiresApproval") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("ShowCoordinatorsOnPublicPage") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SystemTeamType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CustomSlug") + .IsUnique() + .HasFilter("\"CustomSlug\" IS NOT NULL"); + + b.HasIndex("GoogleGroupPrefix") + .IsUnique() + .HasFilter("\"GoogleGroupPrefix\" IS NOT NULL"); + + b.HasIndex("IsActive"); + + b.HasIndex("ParentTeamId"); + + b.HasIndex("Slug") + .IsUnique(); + + b.HasIndex("SystemTeamType"); + + b.ToTable("teams", (string)null); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0001-000000000001"), + CreatedAt = NodaTime.Instant.FromUnixTimeTicks(17702491570000000L), + Description = "All active volunteers with signed required documents", + HasBudget = false, + IsActive = true, + IsHidden = false, + IsPublicPage = false, + IsSensitive = false, + Name = "Volunteers", + RequiresApproval = false, + ShowCoordinatorsOnPublicPage = true, + Slug = "volunteers", + SystemTeamType = "Volunteers", + UpdatedAt = NodaTime.Instant.FromUnixTimeTicks(17702491570000000L) + }, + new + { + Id = new Guid("00000000-0000-0000-0001-000000000002"), + CreatedAt = NodaTime.Instant.FromUnixTimeTicks(17702491570000000L), + Description = "All team coordinators", + HasBudget = false, + IsActive = true, + IsHidden = false, + IsPublicPage = false, + IsSensitive = false, + Name = "Coordinators", + RequiresApproval = false, + ShowCoordinatorsOnPublicPage = true, + Slug = "coordinators", + SystemTeamType = "Coordinators", + UpdatedAt = NodaTime.Instant.FromUnixTimeTicks(17702491570000000L) + }, + new + { + Id = new Guid("00000000-0000-0000-0001-000000000003"), + CreatedAt = NodaTime.Instant.FromUnixTimeTicks(17702491570000000L), + Description = "Board members with active role assignments", + HasBudget = false, + IsActive = true, + IsHidden = false, + IsPublicPage = false, + IsSensitive = false, + Name = "Board", + RequiresApproval = false, + ShowCoordinatorsOnPublicPage = true, + Slug = "board", + SystemTeamType = "Board", + UpdatedAt = NodaTime.Instant.FromUnixTimeTicks(17702491570000000L) + }, + new + { + Id = new Guid("00000000-0000-0000-0001-000000000004"), + CreatedAt = NodaTime.Instant.FromUnixTimeTicks(17702491570000000L), + Description = "Voting members with approved asociado applications", + HasBudget = false, + IsActive = true, + IsHidden = false, + IsPublicPage = false, + IsSensitive = false, + Name = "Asociados", + RequiresApproval = false, + ShowCoordinatorsOnPublicPage = true, + Slug = "asociados", + SystemTeamType = "Asociados", + UpdatedAt = NodaTime.Instant.FromUnixTimeTicks(17702491570000000L) + }, + new + { + Id = new Guid("00000000-0000-0000-0001-000000000005"), + CreatedAt = NodaTime.Instant.FromUnixTimeTicks(17702491570000000L), + Description = "Active contributors with approved colaborador applications", + HasBudget = false, + IsActive = true, + IsHidden = false, + IsPublicPage = false, + IsSensitive = false, + Name = "Colaboradors", + RequiresApproval = false, + ShowCoordinatorsOnPublicPage = true, + Slug = "colaboradors", + SystemTeamType = "Colaboradors", + UpdatedAt = NodaTime.Instant.FromUnixTimeTicks(17702491570000000L) + }, + new + { + Id = new Guid("00000000-0000-0000-0001-000000000006"), + CreatedAt = NodaTime.Instant.FromUnixTimeTicks(17702491570000000L), + Description = "All active camp leads across all camps", + HasBudget = false, + IsActive = true, + IsHidden = false, + IsPublicPage = false, + IsSensitive = false, + Name = "Barrio Leads", + RequiresApproval = false, + ShowCoordinatorsOnPublicPage = true, + Slug = "barrio-leads", + SystemTeamType = "BarrioLeads", + UpdatedAt = NodaTime.Instant.FromUnixTimeTicks(17702491570000000L) + }); + }); + + modelBuilder.Entity("Humans.Domain.Entities.TeamJoinRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Message") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("RequestedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewNotes") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("ReviewedByUserId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("TeamId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ReviewedByUserId"); + + b.HasIndex("Status"); + + b.HasIndex("TeamId"); + + b.HasIndex("UserId"); + + b.HasIndex("TeamId", "UserId", "Status"); + + b.ToTable("team_join_requests", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.TeamJoinRequestStateHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChangedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ChangedByUserId") + .HasColumnType("uuid"); + + b.Property("Notes") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("TeamJoinRequestId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ChangedAt"); + + b.HasIndex("ChangedByUserId"); + + b.HasIndex("TeamJoinRequestId"); + + b.ToTable("team_join_request_state_history", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.TeamMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LeftAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TeamId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Role"); + + b.HasIndex("UserId"); + + b.HasIndex("TeamId", "UserId") + .IsUnique() + .HasDatabaseName("IX_team_members_active_unique") + .HasFilter("\"LeftAt\" IS NULL"); + + b.ToTable("team_members", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.TeamRoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AssignedByUserId") + .HasColumnType("uuid"); + + b.Property("SlotIndex") + .HasColumnType("integer"); + + b.Property("TeamMemberId") + .HasColumnType("uuid"); + + b.Property("TeamRoleDefinitionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AssignedByUserId"); + + b.HasIndex("TeamMemberId"); + + b.HasIndex("TeamRoleDefinitionId", "SlotIndex") + .IsUnique() + .HasDatabaseName("IX_team_role_assignments_definition_slot_unique"); + + b.HasIndex("TeamRoleDefinitionId", "TeamMemberId") + .IsUnique() + .HasDatabaseName("IX_team_role_assignments_definition_member_unique"); + + b.ToTable("team_role_assignments", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.TeamRoleDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("IsManagement") + .HasColumnType("boolean"); + + b.Property("IsPublic") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Period") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Priorities") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasDefaultValueSql("''"); + + b.Property("SlotCount") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("TeamId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("TeamId"); + + b.HasIndex("TeamId", "Name") + .IsUnique() + .HasDatabaseName("IX_team_role_definitions_team_name_unique"); + + b.ToTable("team_role_definitions", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.TicketAttendee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttendeeEmail") + .HasMaxLength(320) + .HasColumnType("character varying(320)"); + + b.Property("AttendeeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("MatchedUserId") + .HasColumnType("uuid"); + + b.Property("Price") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("SyncedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TicketOrderId") + .HasColumnType("uuid"); + + b.Property("TicketTypeName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("VendorEventId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("VendorTicketId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("AttendeeEmail"); + + b.HasIndex("MatchedUserId"); + + b.HasIndex("TicketOrderId"); + + b.HasIndex("VendorTicketId") + .IsUnique(); + + b.ToTable("ticket_attendees", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.TicketOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApplicationFee") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.Property("BuyerEmail") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)"); + + b.Property("BuyerName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)"); + + b.Property("DiscountAmount") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.Property("DiscountCode") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DonationAmount") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.Property("MatchedUserId") + .HasColumnType("uuid"); + + b.Property("PaymentMethod") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PaymentMethodDetail") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PaymentStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("PurchasedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("StripeFee") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.Property("StripePaymentIntentId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SyncedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TotalAmount") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.Property("VatAmount") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.Property("VendorDashboardUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("VendorEventId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("VendorOrderId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("BuyerEmail"); + + b.HasIndex("MatchedUserId"); + + b.HasIndex("PaymentMethod"); + + b.HasIndex("PurchasedAt"); + + b.HasIndex("VendorOrderId") + .IsUnique(); + + b.ToTable("ticket_orders", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.TicketSyncState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("LastError") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("StatusChangedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SyncStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("VendorEventId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.ToTable("ticket_sync_state", (string)null); + + b.HasData( + new + { + Id = 1, + SyncStatus = "Idle", + VendorEventId = "" + }); + }); + + modelBuilder.Entity("Humans.Domain.Entities.TicketingProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AverageTicketPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("BudgetGroupId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DailySalesRate") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("EventDate") + .HasColumnType("date"); + + b.Property("InitialSalesCount") + .HasColumnType("integer"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.Property("StripeFeeFixed") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("StripeFeePercent") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("TicketTailorFeePercent") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VatRate") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("BudgetGroupId") + .IsUnique(); + + b.ToTable("ticketing_projections", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("ContactSource") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletionEligibleAfter") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletionRequestedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletionScheduledFor") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("ExternalSourceId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("GoogleEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("GoogleEmailStatus") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasDefaultValue("Unknown"); + + b.Property("ICalToken") + .HasColumnType("uuid"); + + b.Property("LastConsentReminderSentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("MagicLinkSentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("PreferredLanguage") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasDefaultValue("en"); + + b.Property("ProfilePictureUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("SuppressScheduleChangeEmails") + .HasColumnType("boolean"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UnsubscribedFromCampaigns") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("ContactSource", "ExternalSourceId") + .HasFilter("\"ExternalSourceId\" IS NOT NULL"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.UserEmail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsNotificationTarget") + .HasColumnType("boolean"); + + b.Property("IsOAuth") + .HasColumnType("boolean"); + + b.Property("IsVerified") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("VerificationSentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Visibility") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasFilter("\"IsVerified\" = true"); + + b.HasIndex("UserId"); + + b.ToTable("user_emails", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.VolunteerEventProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Allergies") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("AllergyOtherText") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DietaryPreference") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IntoleranceOtherText") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Intolerances") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Languages") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("MedicalConditions") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Quirks") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Skills") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("volunteer_event_profiles", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.VolunteerHistoryEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("EventName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ProfileId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.ToTable("volunteer_history_entries", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.VolunteerTagPreference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ShiftTagId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ShiftTagId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "ShiftTagId") + .IsUnique() + .HasDatabaseName("IX_volunteer_tag_preferences_user_tag_unique"); + + b.ToTable("volunteer_tag_preferences", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("text"); + + b.Property("Xml") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("role_claims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("user_claims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("user_logins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("user_roles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("user_tokens", (string)null); + }); + + modelBuilder.Entity("RotaShiftTag", b => + { + b.Property("RotaId") + .HasColumnType("uuid"); + + b.Property("ShiftTagId") + .HasColumnType("uuid"); + + b.HasKey("RotaId", "ShiftTagId"); + + b.HasIndex("ShiftTagId"); + + b.ToTable("rota_shift_tags", (string)null); + }); + + modelBuilder.Entity("Humans.Domain.Entities.AccountMergeRequest", b => + { + b.HasOne("Humans.Domain.Entities.User", "ResolvedByUser") + .WithMany() + .HasForeignKey("ResolvedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Humans.Domain.Entities.User", "SourceUser") + .WithMany() + .HasForeignKey("SourceUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Humans.Domain.Entities.User", "TargetUser") + .WithMany() + .HasForeignKey("TargetUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ResolvedByUser"); + + b.Navigation("SourceUser"); + + b.Navigation("TargetUser"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.Application", b => + { + b.HasOne("Humans.Domain.Entities.User", "ReviewedByUser") + .WithMany() + .HasForeignKey("ReviewedByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Humans.Domain.Entities.User", "User") + .WithMany("Applications") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReviewedByUser"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.ApplicationStateHistory", b => + { + b.HasOne("Humans.Domain.Entities.Application", "Application") + .WithMany("StateHistory") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Humans.Domain.Entities.User", "ChangedByUser") + .WithMany() + .HasForeignKey("ChangedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Application"); + + b.Navigation("ChangedByUser"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.AuditLogEntry", b => + { + b.HasOne("Humans.Domain.Entities.User", "ActorUser") + .WithMany() + .HasForeignKey("ActorUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Humans.Domain.Entities.GoogleResource", "Resource") + .WithMany() + .HasForeignKey("ResourceId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("ActorUser"); + + b.Navigation("Resource"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.BoardVote", b => + { + b.HasOne("Humans.Domain.Entities.Application", "Application") + .WithMany("BoardVotes") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Humans.Domain.Entities.User", "BoardMemberUser") + .WithMany() + .HasForeignKey("BoardMemberUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Application"); + + b.Navigation("BoardMemberUser"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.BudgetAuditLog", b => + { + b.HasOne("Humans.Domain.Entities.User", "ActorUser") + .WithMany() + .HasForeignKey("ActorUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Humans.Domain.Entities.BudgetYear", "BudgetYear") + .WithMany("AuditLogs") + .HasForeignKey("BudgetYearId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ActorUser"); + + b.Navigation("BudgetYear"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.BudgetCategory", b => + { + b.HasOne("Humans.Domain.Entities.BudgetGroup", "BudgetGroup") + .WithMany("Categories") + .HasForeignKey("BudgetGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Humans.Domain.Entities.Team", "Team") + .WithMany() + .HasForeignKey("TeamId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("BudgetGroup"); + + b.Navigation("Team"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.BudgetGroup", b => + { + b.HasOne("Humans.Domain.Entities.BudgetYear", "BudgetYear") + .WithMany("Groups") + .HasForeignKey("BudgetYearId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("BudgetYear"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.BudgetLineItem", b => + { + b.HasOne("Humans.Domain.Entities.BudgetCategory", "BudgetCategory") + .WithMany("LineItems") + .HasForeignKey("BudgetCategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Humans.Domain.Entities.Team", "ResponsibleTeam") + .WithMany() + .HasForeignKey("ResponsibleTeamId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("BudgetCategory"); + + b.Navigation("ResponsibleTeam"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.Camp", b => + { + b.HasOne("Humans.Domain.Entities.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CreatedByUser"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.CampHistoricalName", b => + { + b.HasOne("Humans.Domain.Entities.Camp", "Camp") + .WithMany("HistoricalNames") + .HasForeignKey("CampId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Camp"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.CampImage", b => + { + b.HasOne("Humans.Domain.Entities.Camp", "Camp") + .WithMany("Images") + .HasForeignKey("CampId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Camp"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.CampLead", b => + { + b.HasOne("Humans.Domain.Entities.Camp", "Camp") + .WithMany("Leads") + .HasForeignKey("CampId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Humans.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Camp"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.CampPolygon", b => + { + b.HasOne("Humans.Domain.Entities.CampSeason", "CampSeason") + .WithMany() + .HasForeignKey("CampSeasonId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Humans.Domain.Entities.User", "LastModifiedByUser") + .WithMany() + .HasForeignKey("LastModifiedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CampSeason"); + + b.Navigation("LastModifiedByUser"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.CampPolygonHistory", b => + { + b.HasOne("Humans.Domain.Entities.CampSeason", "CampSeason") + .WithMany() + .HasForeignKey("CampSeasonId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Humans.Domain.Entities.User", "ModifiedByUser") + .WithMany() + .HasForeignKey("ModifiedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CampSeason"); + + b.Navigation("ModifiedByUser"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.CampSeason", b => + { + b.HasOne("Humans.Domain.Entities.Camp", "Camp") + .WithMany("Seasons") + .HasForeignKey("CampId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Humans.Domain.Entities.User", "ReviewedByUser") + .WithMany() + .HasForeignKey("ReviewedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Camp"); + + b.Navigation("ReviewedByUser"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.Campaign", b => + { + b.HasOne("Humans.Domain.Entities.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CreatedByUser"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.CampaignCode", b => + { + b.HasOne("Humans.Domain.Entities.Campaign", "Campaign") + .WithMany("Codes") + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Campaign"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.CampaignGrant", b => + { + b.HasOne("Humans.Domain.Entities.CampaignCode", "Code") + .WithOne("Grant") + .HasForeignKey("Humans.Domain.Entities.CampaignGrant", "CampaignCodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Humans.Domain.Entities.Campaign", "Campaign") + .WithMany("Grants") + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Humans.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Campaign"); + + b.Navigation("Code"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.CommunicationPreference", b => + { + b.HasOne("Humans.Domain.Entities.User", "User") + .WithMany("CommunicationPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.ConsentRecord", b => + { + b.HasOne("Humans.Domain.Entities.DocumentVersion", "DocumentVersion") + .WithMany("ConsentRecords") + .HasForeignKey("DocumentVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Humans.Domain.Entities.User", "User") + .WithMany("ConsentRecords") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("DocumentVersion"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.ContactField", b => + { + b.HasOne("Humans.Domain.Entities.Profile", "Profile") + .WithMany("ContactFields") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.DocumentVersion", b => + { + b.HasOne("Humans.Domain.Entities.LegalDocument", "LegalDocument") + .WithMany("Versions") + .HasForeignKey("LegalDocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LegalDocument"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.EmailOutboxMessage", b => + { + b.HasOne("Humans.Domain.Entities.CampaignGrant", "CampaignGrant") + .WithMany("OutboxMessages") + .HasForeignKey("CampaignGrantId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Humans.Domain.Entities.ShiftSignup", "ShiftSignup") + .WithMany() + .HasForeignKey("ShiftSignupId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Humans.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CampaignGrant"); + + b.Navigation("ShiftSignup"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.EventParticipation", b => + { + b.HasOne("Humans.Domain.Entities.User", "User") + .WithMany("EventParticipations") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.FeedbackMessage", b => + { + b.HasOne("Humans.Domain.Entities.FeedbackReport", "FeedbackReport") + .WithMany("Messages") + .HasForeignKey("FeedbackReportId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Humans.Domain.Entities.User", "SenderUser") + .WithMany() + .HasForeignKey("SenderUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("FeedbackReport"); + + b.Navigation("SenderUser"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.FeedbackReport", b => + { + b.HasOne("Humans.Domain.Entities.Team", "AssignedToTeam") + .WithMany() + .HasForeignKey("AssignedToTeamId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Humans.Domain.Entities.User", "AssignedToUser") + .WithMany() + .HasForeignKey("AssignedToUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Humans.Domain.Entities.User", "ResolvedByUser") + .WithMany() + .HasForeignKey("ResolvedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Humans.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AssignedToTeam"); + + b.Navigation("AssignedToUser"); + + b.Navigation("ResolvedByUser"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.GeneralAvailability", b => + { + b.HasOne("Humans.Domain.Entities.EventSettings", "EventSettings") + .WithMany() + .HasForeignKey("EventSettingsId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Humans.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("EventSettings"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.GoogleResource", b => + { + b.HasOne("Humans.Domain.Entities.Team", "Team") + .WithMany() + .HasForeignKey("TeamId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Team"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.GoogleSyncOutboxEvent", b => + { + b.HasOne("Humans.Domain.Entities.Team", null) + .WithMany() + .HasForeignKey("TeamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Humans.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Humans.Domain.Entities.LegalDocument", b => + { + b.HasOne("Humans.Domain.Entities.Team", "Team") + .WithMany("LegalDocuments") + .HasForeignKey("TeamId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Team"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.Notification", b => + { + b.HasOne("Humans.Domain.Entities.User", "ResolvedByUser") + .WithMany() + .HasForeignKey("ResolvedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("ResolvedByUser"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.NotificationRecipient", b => + { + b.HasOne("Humans.Domain.Entities.Notification", "Notification") + .WithMany("Recipients") + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Humans.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.Profile", b => + { + b.HasOne("Humans.Domain.Entities.User", "User") + .WithOne("Profile") + .HasForeignKey("Humans.Domain.Entities.Profile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.ProfileLanguage", b => + { + b.HasOne("Humans.Domain.Entities.Profile", "Profile") + .WithMany("Languages") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.RoleAssignment", b => + { + b.HasOne("Humans.Domain.Entities.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Humans.Domain.Entities.User", "User") + .WithMany("RoleAssignments") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.Rota", b => + { + b.HasOne("Humans.Domain.Entities.EventSettings", "EventSettings") + .WithMany("Rotas") + .HasForeignKey("EventSettingsId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Humans.Domain.Entities.Team", "Team") + .WithMany() + .HasForeignKey("TeamId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("EventSettings"); + + b.Navigation("Team"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.Shift", b => + { + b.HasOne("Humans.Domain.Entities.Rota", "Rota") + .WithMany("Shifts") + .HasForeignKey("RotaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Rota"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.ShiftSignup", b => + { + b.HasOne("Humans.Domain.Entities.User", "EnrolledByUser") + .WithMany() + .HasForeignKey("EnrolledByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Humans.Domain.Entities.User", "ReviewedByUser") + .WithMany() + .HasForeignKey("ReviewedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Humans.Domain.Entities.Shift", "Shift") + .WithMany("ShiftSignups") + .HasForeignKey("ShiftId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Humans.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("EnrolledByUser"); + + b.Navigation("ReviewedByUser"); + + b.Navigation("Shift"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.SyncServiceSettings", b => + { + b.HasOne("Humans.Domain.Entities.User", "UpdatedByUser") + .WithMany() + .HasForeignKey("UpdatedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("UpdatedByUser"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.Team", b => + { + b.HasOne("Humans.Domain.Entities.Team", "ParentTeam") + .WithMany("ChildTeams") + .HasForeignKey("ParentTeamId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("ParentTeam"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.TeamJoinRequest", b => + { + b.HasOne("Humans.Domain.Entities.User", "ReviewedByUser") + .WithMany() + .HasForeignKey("ReviewedByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Humans.Domain.Entities.Team", "Team") + .WithMany("JoinRequests") + .HasForeignKey("TeamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Humans.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReviewedByUser"); + + b.Navigation("Team"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.TeamJoinRequestStateHistory", b => + { + b.HasOne("Humans.Domain.Entities.User", "ChangedByUser") + .WithMany() + .HasForeignKey("ChangedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Humans.Domain.Entities.TeamJoinRequest", "TeamJoinRequest") + .WithMany("StateHistory") + .HasForeignKey("TeamJoinRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ChangedByUser"); + + b.Navigation("TeamJoinRequest"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.TeamMember", b => + { + b.HasOne("Humans.Domain.Entities.Team", "Team") + .WithMany("Members") + .HasForeignKey("TeamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Humans.Domain.Entities.User", "User") + .WithMany("TeamMemberships") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Team"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.TeamRoleAssignment", b => + { + b.HasOne("Humans.Domain.Entities.User", "AssignedByUser") + .WithMany() + .HasForeignKey("AssignedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Humans.Domain.Entities.TeamMember", "TeamMember") + .WithMany("RoleAssignments") + .HasForeignKey("TeamMemberId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Humans.Domain.Entities.TeamRoleDefinition", "TeamRoleDefinition") + .WithMany("Assignments") + .HasForeignKey("TeamRoleDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AssignedByUser"); + + b.Navigation("TeamMember"); + + b.Navigation("TeamRoleDefinition"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.TeamRoleDefinition", b => + { + b.HasOne("Humans.Domain.Entities.Team", "Team") + .WithMany("RoleDefinitions") + .HasForeignKey("TeamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Team"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.TicketAttendee", b => + { + b.HasOne("Humans.Domain.Entities.User", "MatchedUser") + .WithMany() + .HasForeignKey("MatchedUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Humans.Domain.Entities.TicketOrder", "TicketOrder") + .WithMany("Attendees") + .HasForeignKey("TicketOrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MatchedUser"); + + b.Navigation("TicketOrder"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.TicketOrder", b => + { + b.HasOne("Humans.Domain.Entities.User", "MatchedUser") + .WithMany() + .HasForeignKey("MatchedUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("MatchedUser"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.TicketingProjection", b => + { + b.HasOne("Humans.Domain.Entities.BudgetGroup", "BudgetGroup") + .WithOne("TicketingProjection") + .HasForeignKey("Humans.Domain.Entities.TicketingProjection", "BudgetGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("BudgetGroup"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.UserEmail", b => + { + b.HasOne("Humans.Domain.Entities.User", "User") + .WithMany("UserEmails") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.VolunteerEventProfile", b => + { + b.HasOne("Humans.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.VolunteerHistoryEntry", b => + { + b.HasOne("Humans.Domain.Entities.Profile", "Profile") + .WithMany("VolunteerHistory") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.VolunteerTagPreference", b => + { + b.HasOne("Humans.Domain.Entities.ShiftTag", "ShiftTag") + .WithMany("VolunteerPreferences") + .HasForeignKey("ShiftTagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Humans.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ShiftTag"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Humans.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Humans.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Humans.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Humans.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RotaShiftTag", b => + { + b.HasOne("Humans.Domain.Entities.Rota", null) + .WithMany() + .HasForeignKey("RotaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Humans.Domain.Entities.ShiftTag", null) + .WithMany() + .HasForeignKey("ShiftTagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Humans.Domain.Entities.Application", b => + { + b.Navigation("BoardVotes"); + + b.Navigation("StateHistory"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.BudgetCategory", b => + { + b.Navigation("LineItems"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.BudgetGroup", b => + { + b.Navigation("Categories"); + + b.Navigation("TicketingProjection"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.BudgetYear", b => + { + b.Navigation("AuditLogs"); + + b.Navigation("Groups"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.Camp", b => + { + b.Navigation("HistoricalNames"); + + b.Navigation("Images"); + + b.Navigation("Leads"); + + b.Navigation("Seasons"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.Campaign", b => + { + b.Navigation("Codes"); + + b.Navigation("Grants"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.CampaignCode", b => + { + b.Navigation("Grant"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.CampaignGrant", b => + { + b.Navigation("OutboxMessages"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.DocumentVersion", b => + { + b.Navigation("ConsentRecords"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.EventSettings", b => + { + b.Navigation("Rotas"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.FeedbackReport", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.LegalDocument", b => + { + b.Navigation("Versions"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.Notification", b => + { + b.Navigation("Recipients"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.Profile", b => + { + b.Navigation("ContactFields"); + + b.Navigation("Languages"); + + b.Navigation("VolunteerHistory"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.Rota", b => + { + b.Navigation("Shifts"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.Shift", b => + { + b.Navigation("ShiftSignups"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.ShiftTag", b => + { + b.Navigation("VolunteerPreferences"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.Team", b => + { + b.Navigation("ChildTeams"); + + b.Navigation("JoinRequests"); + + b.Navigation("LegalDocuments"); + + b.Navigation("Members"); + + b.Navigation("RoleDefinitions"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.TeamJoinRequest", b => + { + b.Navigation("StateHistory"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.TeamMember", b => + { + b.Navigation("RoleAssignments"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.TeamRoleDefinition", b => + { + b.Navigation("Assignments"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.TicketOrder", b => + { + b.Navigation("Attendees"); + }); + + modelBuilder.Entity("Humans.Domain.Entities.User", b => + { + b.Navigation("Applications"); + + b.Navigation("CommunicationPreferences"); + + b.Navigation("ConsentRecords"); + + b.Navigation("EventParticipations"); + + b.Navigation("Profile"); + + b.Navigation("RoleAssignments"); + + b.Navigation("TeamMemberships"); + + b.Navigation("UserEmails"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Humans.Infrastructure/Migrations/20260414215135_RestrictGoogleResourceTeamDelete.cs b/src/Humans.Infrastructure/Migrations/20260414215135_RestrictGoogleResourceTeamDelete.cs new file mode 100644 index 000000000..403474b69 --- /dev/null +++ b/src/Humans.Infrastructure/Migrations/20260414215135_RestrictGoogleResourceTeamDelete.cs @@ -0,0 +1,42 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Humans.Infrastructure.Migrations +{ + /// + public partial class RestrictGoogleResourceTeamDelete : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_google_resources_teams_TeamId", + table: "google_resources"); + + migrationBuilder.AddForeignKey( + name: "FK_google_resources_teams_TeamId", + table: "google_resources", + column: "TeamId", + principalTable: "teams", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_google_resources_teams_TeamId", + table: "google_resources"); + + migrationBuilder.AddForeignKey( + name: "FK_google_resources_teams_TeamId", + table: "google_resources", + column: "TeamId", + principalTable: "teams", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + } + } +} diff --git a/src/Humans.Infrastructure/Migrations/HumansDbContextModelSnapshot.cs b/src/Humans.Infrastructure/Migrations/HumansDbContextModelSnapshot.cs index 9e86972e0..25b74ba7f 100644 --- a/src/Humans.Infrastructure/Migrations/HumansDbContextModelSnapshot.cs +++ b/src/Humans.Infrastructure/Migrations/HumansDbContextModelSnapshot.cs @@ -4011,7 +4011,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasOne("Humans.Domain.Entities.Team", "Team") .WithMany() .HasForeignKey("TeamId") - .OnDelete(DeleteBehavior.SetNull) + .OnDelete(DeleteBehavior.Restrict) .IsRequired(); b.Navigation("Team"); diff --git a/src/Humans.Infrastructure/Services/StubTeamResourceService.cs b/src/Humans.Infrastructure/Services/StubTeamResourceService.cs index 55b92214c..8f5fb40a7 100644 --- a/src/Humans.Infrastructure/Services/StubTeamResourceService.cs +++ b/src/Humans.Infrastructure/Services/StubTeamResourceService.cs @@ -127,41 +127,15 @@ public async Task> GetUserTeamResourcesAsy Guid userId, CancellationToken ct = default) { - var memberships = await TeamService.GetUserTeamsAsync(userId, ct); - if (memberships.Count == 0) - { - return Array.Empty(); - } - - var teamById = memberships - .Select(tm => tm.Team) - .Where(t => t is not null) - .GroupBy(t => t!.Id) - .ToDictionary(g => g.Key, g => g.First()!); - - var teamIds = teamById.Keys.ToList(); - - var resources = await _dbContext.GoogleResources - .AsNoTracking() - .Where(r => teamIds.Contains(r.TeamId) && r.IsActive) - .ToListAsync(ct); - - return resources - .Select(r => new - { - Team = teamById.TryGetValue(r.TeamId, out var t) ? t : null, - Resource = r - }) - .Where(x => x.Team is not null) - .OrderBy(x => x.Team!.Name, StringComparer.OrdinalIgnoreCase) - .ThenBy(x => x.Resource.Name, StringComparer.OrdinalIgnoreCase) - .Select(x => new UserTeamGoogleResource( - x.Team!.Name, - x.Team.Slug, - x.Resource.Name, - x.Resource.ResourceType, - x.Resource.Url)) - .ToList(); + return await ( + from tm in _dbContext.TeamMembers.AsNoTracking() + where tm.UserId == userId && tm.LeftAt == null + join t in _dbContext.Teams on tm.TeamId equals t.Id + join r in _dbContext.GoogleResources on t.Id equals r.TeamId + where r.IsActive + orderby t.Name, r.Name + select new UserTeamGoogleResource(t.Name, t.Slug, r.Name, r.ResourceType, r.Url) + ).ToListAsync(ct); } /// @@ -176,7 +150,7 @@ public async Task> GetActiveDriveFoldersAsync(Canc /// public async Task GetResourceCountAsync(CancellationToken ct = default) { - return await _dbContext.GoogleResources.CountAsync(ct); + return await _dbContext.GoogleResources.AsNoTracking().CountAsync(ct); } /// diff --git a/src/Humans.Infrastructure/Services/TeamResourceService.cs b/src/Humans.Infrastructure/Services/TeamResourceService.cs index af8ed9972..99fac4ea5 100644 --- a/src/Humans.Infrastructure/Services/TeamResourceService.cs +++ b/src/Humans.Infrastructure/Services/TeamResourceService.cs @@ -145,41 +145,15 @@ public async Task> GetUserTeamResourcesAsy Guid userId, CancellationToken ct = default) { - var memberships = await TeamService.GetUserTeamsAsync(userId, ct); - if (memberships.Count == 0) - { - return Array.Empty(); - } - - var teamById = memberships - .Select(tm => tm.Team) - .Where(t => t is not null) - .GroupBy(t => t!.Id) - .ToDictionary(g => g.Key, g => g.First()!); - - var teamIds = teamById.Keys.ToList(); - - var resources = await _dbContext.GoogleResources - .AsNoTracking() - .Where(r => teamIds.Contains(r.TeamId) && r.IsActive) - .ToListAsync(ct); - - return resources - .Select(r => new - { - Team = teamById.TryGetValue(r.TeamId, out var t) ? t : null, - Resource = r - }) - .Where(x => x.Team is not null) - .OrderBy(x => x.Team!.Name, StringComparer.OrdinalIgnoreCase) - .ThenBy(x => x.Resource.Name, StringComparer.OrdinalIgnoreCase) - .Select(x => new UserTeamGoogleResource( - x.Team!.Name, - x.Team.Slug, - x.Resource.Name, - x.Resource.ResourceType, - x.Resource.Url)) - .ToList(); + return await ( + from tm in _dbContext.TeamMembers.AsNoTracking() + where tm.UserId == userId && tm.LeftAt == null + join t in _dbContext.Teams on tm.TeamId equals t.Id + join r in _dbContext.GoogleResources on t.Id equals r.TeamId + where r.IsActive + orderby t.Name, r.Name + select new UserTeamGoogleResource(t.Name, t.Slug, r.Name, r.ResourceType, r.Url) + ).ToListAsync(ct); } /// @@ -194,7 +168,7 @@ public async Task> GetActiveDriveFoldersAsync(Canc /// public async Task GetResourceCountAsync(CancellationToken ct = default) { - return await _dbContext.GoogleResources.CountAsync(ct); + return await _dbContext.GoogleResources.AsNoTracking().CountAsync(ct); } ///