diff --git a/docs/features/20-camps.md b/docs/features/20-camps.md index 1a6595f3..2ea7f4f0 100644 --- a/docs/features/20-camps.md +++ b/docs/features/20-camps.md @@ -121,6 +121,25 @@ Nobodies Collective organizes camping areas ("barrios") at Nowhere and related e - `GET /api/camps/{year}/placement` returns placement-relevant data (space, sound zone, containers, electrical) - Both endpoints are public (no authentication required) +### US-20.12: Per-season Camp Membership (issue #488) +**As an** authenticated human +**I want to** tell Humans which camp I have joined for this year +**So that** the system can assign me camp-specific roles and early-entry slots + +**Important framing:** Humans does NOT manage a camp's real membership. Each camp runs its own process (website, spreadsheet, WhatsApp). A `CampMember` record is a **post-hoc** representation — humans join through the camp's own channel first, then come to Humans to record the relationship. The UI must make this clear. + +**Acceptance Criteria:** +- On `/Camps/{slug}`, authenticated humans see a "Request to join for {year}" button when the camp has an Active or Full season for the current public year. Tooltip clarifies that this doesn't join you to the camp — do that through the camp's own process first. +- When no eligible open season exists the button is disabled with "Camp not open for membership this year". +- Clicking creates a `CampMember` row with status `Pending` and the button changes to "Request pending". +- Camp leads and CampAdmin/Admin see a "Humans in this camp" section on `/Camps/{slug}/Edit` listing pending requests (Approve / Reject) and active members (Remove). +- On Approve: status → `Active`, `ConfirmedAt`/`ConfirmedByUserId` set, the requester receives an in-app notification (`CampMembershipApproved`). +- On Reject: status → `Removed`, requester is notified (`CampMembershipRejected`). Removed rows are preserved for audit and allow re-requesting later. +- A human can withdraw their own pending request and leave their own active membership from the camp detail page. +- When a season transitions to `Rejected` or `Withdrawn`, any pending membership rows for that season are auto-withdrawn (status → Removed). +- The member list is **private** — never rendered on anonymous or public views, only to camp leads, CampAdmin, Admin, and the human themselves. +- Humans see their own camps, grouped by year (Active + Pending), on their profile page via the `MyCamps` view component. + ### US-20.11: Export Camps CSV **As a** CampAdmin or Admin **I want to** export camp data as CSV @@ -208,10 +227,12 @@ CampSettings ### Supporting Entities - **CampHistoricalName**: Id, CampId, Name, Year (int?), Source (CampNameSource), CreatedAt - **CampImage**: Id, CampId, FileName, StoragePath, ContentType, SortOrder, UploadedAt +- **CampMember**: Id, CampSeasonId, UserId, Status (CampMemberStatus), RequestedAt, ConfirmedAt?, ConfirmedByUserId?, RemovedAt?, RemovedByUserId?. Unique index on `(CampSeasonId, UserId) WHERE Status <> 'Removed'` — one active/pending row per season; removed rows allow re-requesting. ### Enums ``` CampSeasonStatus: Pending(0), Active(1), Full(2), Rejected(4), Withdrawn(5) +CampMemberStatus: Pending(0), Active(1), Removed(2) CampLeadRole: Primary(0), CoLead(1) CampVibe: Adult(0), ChillOut(1), ElectronicMusic(2), Games(3), Queer(4), Sober(5), Lecture(6), LiveMusic(7), Wellness(8), Workshop(9) CampNameSource: Manual(0), NameChange(1) @@ -310,6 +331,11 @@ Transitions: | Set name lock date | CampAdmin or Admin | | Delete camp | Admin only | | JSON API | Public (AllowAnonymous) | +| Request camp membership | Authenticated | +| Withdraw own membership request | Authenticated (owner only) | +| Leave own camp membership | Authenticated (owner only) | +| Approve / Reject / Remove camp member | Camp Lead, CampAdmin, or Admin | +| View camp member list | Camp Lead, CampAdmin, or Admin (never public) | ## URL Structure @@ -337,6 +363,12 @@ Transitions: | `POST /Camps/Admin/SetPublicYear` | Set public year | | `POST /Camps/Admin/SetNameLockDate` | Set name lock date | | `POST /Camps/Admin/Delete/{campId}` | Delete camp | +| `POST /Camps/{slug}/Members/Request` | Request to join camp (authenticated human) | +| `POST /Camps/{slug}/Members/Withdraw/{campMemberId}` | Withdraw own pending request | +| `POST /Camps/{slug}/Members/Leave/{campMemberId}` | Leave own active membership | +| `POST /Camps/{slug}/Members/Approve/{campMemberId}` | Approve pending request (lead/CampAdmin) | +| `POST /Camps/{slug}/Members/Reject/{campMemberId}` | Reject pending request (lead/CampAdmin) | +| `POST /Camps/{slug}/Members/Remove/{campMemberId}` | Remove active member (lead/CampAdmin) | | `GET /api/camps/{year}` | JSON API: camps for year | | `GET /api/camps/{year}/placement` | JSON API: placement data | diff --git a/docs/sections/Camps.md b/docs/sections/Camps.md index bfff3db1..c7a34cc1 100644 --- a/docs/sections/Camps.md +++ b/docs/sections/Camps.md @@ -5,6 +5,7 @@ - A **Camp** (also called "Barrio") is a themed community camp. Each camp has a unique URL slug, one or more leads, and optional images. - A **Camp Season** is a per-year registration for a camp, containing the year-specific name, description, community info, and placement details. - A **Camp Lead** is a human responsible for managing a camp. Leads have a role: Primary or CoLead. +- A **Camp Member** is a human who belongs to a camp for a specific season. Membership is **post-hoc** — humans join through the camp's own process (website, spreadsheet, WhatsApp) first, then tell Humans about it. Per-season, not per-camp; no carry-forward across years. Private: never rendered on anonymous or public views. - **Camp Settings** is a singleton controlling which year is public (shown in the directory) and which seasons accept new registrations. ## Actors & Roles @@ -12,8 +13,8 @@ | Actor | Capabilities | |-------|-------------| | Anyone (including anonymous) | Browse the camps directory, view camp details and season details | -| Any authenticated human | Register a new camp (which creates a new season in Pending status) | -| Camp lead | Edit their camp's details, manage season registrations, manage co-leads, upload/manage images, manage historical names | +| Any authenticated human | Register a new camp (which creates a new season in Pending status). Request to join a camp for the open season. Withdraw own pending request. Leave own active membership. | +| Camp lead | Edit their camp's details, manage season registrations, manage co-leads, upload/manage images, manage historical names. Approve/reject pending member requests. Remove active members. View the camp member list. | | CampAdmin, Admin | All camp lead capabilities on all camps. Approve/reject season registrations. Manage camp settings (public year, open seasons, name lock dates). View withdrawn and rejected seasons. Export camp data | | Admin | Delete camps | @@ -25,18 +26,24 @@ - Camp images are stored on disk; metadata and display order are tracked per camp. - Historical names are recorded when a camp is renamed. - Camp settings control which year is shown publicly and which seasons accept registrations. +- Camp membership is per-season. A human can only have one active or pending `CampMember` row per season (enforced by a partial unique index on `(CampSeasonId, UserId) WHERE Status <> 'Removed'`). Removed rows are retained for audit and allow re-requesting. +- A camp is "open for membership" for the current public year only when its season for that year is Active or Full. Pending/Rejected/Withdrawn seasons do not accept membership requests. +- Camp member lists are never rendered on anonymous or public views. ## Negative Access Rules - Regular humans **cannot** edit camps they do not lead. - Camp leads **cannot** approve or reject season registrations — that requires CampAdmin or Admin. - CampAdmin **cannot** delete camps. Only Admin can delete a camp. -- Anonymous visitors **cannot** register camps or edit any camp data. +- Anonymous visitors **cannot** register camps, edit any camp data, request to join a camp, or see a camp's member list. +- A human **cannot** withdraw another human's pending membership request or leave another human's active membership. ## Triggers - When a camp is registered, its initial season is created with Pending status. - Season approval or rejection is performed by CampAdmin. +- When a camp season transitions to `Rejected` or `Withdrawn`, all pending `CampMember` rows for that season are auto-withdrawn (status → Removed). +- When a camp membership request is approved or rejected, the requester receives an in-app notification (`CampMembershipApproved` / `CampMembershipRejected`). ## Cross-Section Dependencies @@ -48,7 +55,7 @@ See `.claude/DESIGN_RULES.md` for the full rules. **Owning services:** `CampService`, `CampContactService` -**Owned tables:** `camps`, `camp_seasons`, `camp_leads`, `camp_images`, `camp_historical_names`, `camp_settings` +**Owned tables:** `camps`, `camp_seasons`, `camp_leads`, `camp_members`, `camp_images`, `camp_historical_names`, `camp_settings` ### Current Violations diff --git a/src/Humans.Application/Interfaces/ICampService.cs b/src/Humans.Application/Interfaces/ICampService.cs index d9ea96d2..5e4f33a2 100644 --- a/src/Humans.Application/Interfaces/ICampService.cs +++ b/src/Humans.Application/Interfaces/ICampService.cs @@ -99,6 +99,67 @@ Task UpdateCampAsync(Guid campId, string contactEmail, string contactPhone, // Name change (handles historical name logging) Task ChangeSeasonNameAsync(Guid seasonId, string newName, CancellationToken cancellationToken = default); + + // ====================================================================== + // Camp membership per season + // ====================================================================== + + /// + /// Human requests to join a camp for the currently-open season. + /// Creates a in Pending status. No-op if an active or pending + /// record already exists for the human in that season. + /// + Task RequestCampMembershipAsync( + Guid campId, Guid userId, CancellationToken cancellationToken = default); + + /// + /// Approve a pending camp membership request. Sets status to Active. + /// + Task ApproveCampMemberAsync( + Guid campMemberId, Guid approvedByUserId, CancellationToken cancellationToken = default); + + /// + /// Reject a pending camp membership request. Sets status to Removed. + /// + Task RejectCampMemberAsync( + Guid campMemberId, Guid rejectedByUserId, CancellationToken cancellationToken = default); + + /// + /// Lead removes an active camp member. Sets status to Removed. + /// + Task RemoveCampMemberAsync( + Guid campMemberId, Guid removedByUserId, CancellationToken cancellationToken = default); + + /// + /// Human withdraws their own pending request. Sets status to Removed. + /// + Task WithdrawCampMembershipRequestAsync( + Guid campMemberId, Guid userId, CancellationToken cancellationToken = default); + + /// + /// Human leaves an active membership. Sets status to Removed. + /// + Task LeaveCampAsync( + Guid campMemberId, Guid userId, CancellationToken cancellationToken = default); + + /// + /// Gets the current user's membership state for a camp's open-season (or null if no open season). + /// + Task GetMembershipStateForCampAsync( + Guid campId, Guid userId, CancellationToken cancellationToken = default); + + /// + /// Lists members (pending + active) for a given camp season. Privileged view. + /// + Task GetCampMembersAsync( + Guid campSeasonId, CancellationToken cancellationToken = default); + + /// + /// Lists camps a human belongs to or has requested, grouped by year. + /// Used for the human's own profile dashboard. + /// + Task> GetCampMembershipsForUserAsync( + Guid userId, CancellationToken cancellationToken = default); } public record CampSeasonData( @@ -276,3 +337,68 @@ public record CampSeasonDisplayData(string Name, string CampSlug, SoundZone? Sou /// Lightweight camp season summary (ID, name, camp slug) for listing. /// public record CampSeasonBrief(Guid CampSeasonId, string Name, string CampSlug); + +/// +/// Result of a camp membership request action. +/// +public record CampMemberRequestResult( + Guid CampMemberId, + CampMemberRequestOutcome Outcome, + string? Message = null); + +public enum CampMemberRequestOutcome +{ + /// A new pending request was created. + Created, + /// An existing pending request already existed for the human. + AlreadyPending, + /// The human is already an active member of the camp for this season. + AlreadyActive, + /// No open season for the camp — the request was not created. + NoOpenSeason +} + +/// +/// Current human's camp membership state relative to a camp's open-season. +/// +public record CampMembershipState( + int? OpenSeasonYear, + Guid? OpenSeasonId, + Guid? CampMemberId, + CampMemberStatusSummary Status); + +public enum CampMemberStatusSummary +{ + /// No open season for the camp. + NoOpenSeason, + /// Open season exists but human has no record. + None, + /// Human has a pending request. + Pending, + /// Human is an active member. + Active +} + +public record CampMemberListData( + Guid CampSeasonId, + int Year, + IReadOnlyList Pending, + IReadOnlyList Active); + +public record CampMemberRow( + Guid CampMemberId, + Guid UserId, + string DisplayName, + Instant RequestedAt, + Instant? ConfirmedAt); + +public record CampMembershipSummary( + Guid CampMemberId, + Guid CampId, + string CampSlug, + string CampName, + Guid CampSeasonId, + int Year, + CampMemberStatus Status, + Instant RequestedAt, + Instant? ConfirmedAt); diff --git a/src/Humans.Application/NotificationSourceMapping.cs b/src/Humans.Application/NotificationSourceMapping.cs index a5dc2a3a..fb44863a 100644 --- a/src/Humans.Application/NotificationSourceMapping.cs +++ b/src/Humans.Application/NotificationSourceMapping.cs @@ -33,6 +33,9 @@ public static class NotificationSourceMapping NotificationSource.GoogleDriftDetected => MessageCategory.System, NotificationSource.FacilitatedMessageReceived => MessageCategory.FacilitatedMessages, NotificationSource.LegalDocumentPublished => MessageCategory.System, + NotificationSource.CampMembershipRequested => MessageCategory.TeamUpdates, + NotificationSource.CampMembershipApproved => MessageCategory.TeamUpdates, + NotificationSource.CampMembershipRejected => MessageCategory.TeamUpdates, _ => MessageCategory.System }; } diff --git a/src/Humans.Domain/Entities/CampMember.cs b/src/Humans.Domain/Entities/CampMember.cs new file mode 100644 index 00000000..00d192d7 --- /dev/null +++ b/src/Humans.Domain/Entities/CampMember.cs @@ -0,0 +1,35 @@ +using Humans.Domain.Enums; +using NodaTime; + +namespace Humans.Domain.Entities; + +/// +/// A human's membership in a camp for a given season. This is a post-hoc record — +/// humans join a camp through the camp's own process (website, spreadsheet, WhatsApp) first, +/// then create a CampMember so Humans knows about the relationship for roles, early entry, +/// and notifications. +/// +/// Per-season, not per-camp. Privacy: never rendered on anonymous or public views. +/// +public class CampMember +{ + public Guid Id { get; init; } + + public Guid CampSeasonId { get; init; } + public CampSeason CampSeason { get; set; } = null!; + + public Guid UserId { get; init; } + public User User { get; set; } = null!; + + public CampMemberStatus Status { get; set; } = CampMemberStatus.Pending; + + public Instant RequestedAt { get; init; } + + public Instant? ConfirmedAt { get; set; } + public Guid? ConfirmedByUserId { get; set; } + public User? ConfirmedByUser { get; set; } + + public Instant? RemovedAt { get; set; } + public Guid? RemovedByUserId { get; set; } + public User? RemovedByUser { get; set; } +} diff --git a/src/Humans.Domain/Enums/AuditAction.cs b/src/Humans.Domain/Enums/AuditAction.cs index a88f4977..bb871308 100644 --- a/src/Humans.Domain/Enums/AuditAction.cs +++ b/src/Humans.Domain/Enums/AuditAction.cs @@ -51,6 +51,12 @@ public enum AuditAction CampPrimaryLeadTransferred, CampImageUploaded, CampImageDeleted, + CampMemberRequested, + CampMemberApproved, + CampMemberRejected, + CampMemberRemoved, + CampMemberWithdrawn, + CampMemberLeft, ShiftSignupConfirmed, ShiftSignupRefused, ShiftSignupVoluntold, diff --git a/src/Humans.Domain/Enums/CampMemberStatus.cs b/src/Humans.Domain/Enums/CampMemberStatus.cs new file mode 100644 index 00000000..82650c5a --- /dev/null +++ b/src/Humans.Domain/Enums/CampMemberStatus.cs @@ -0,0 +1,16 @@ +namespace Humans.Domain.Enums; + +/// +/// Status of a human's membership in a camp for a specific season. +/// +public enum CampMemberStatus +{ + /// The human has requested membership and is awaiting lead approval. + Pending = 0, + + /// The human is an active member of the camp for the season. + Active = 1, + + /// Membership was removed or withdrawn. Soft-deleted row preserved for audit. + Removed = 2 +} diff --git a/src/Humans.Domain/Enums/NotificationSource.cs b/src/Humans.Domain/Enums/NotificationSource.cs index ed70a500..c3b6e569 100644 --- a/src/Humans.Domain/Enums/NotificationSource.cs +++ b/src/Humans.Domain/Enums/NotificationSource.cs @@ -76,5 +76,14 @@ public enum NotificationSource FacilitatedMessageReceived = 23, /// A new legal document version was published. - LegalDocumentPublished = 24 + LegalDocumentPublished = 24, + + /// A camp membership request was approved by a lead. + CampMembershipApproved = 25, + + /// A camp membership request was rejected by a lead. + CampMembershipRejected = 26, + + /// A human requested membership in a camp (notifies leads). + CampMembershipRequested = 27 } diff --git a/src/Humans.Infrastructure/Data/Configurations/CampMemberConfiguration.cs b/src/Humans.Infrastructure/Data/Configurations/CampMemberConfiguration.cs new file mode 100644 index 00000000..7452ed1d --- /dev/null +++ b/src/Humans.Infrastructure/Data/Configurations/CampMemberConfiguration.cs @@ -0,0 +1,45 @@ +using Humans.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Humans.Infrastructure.Data.Configurations; + +public class CampMemberConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("camp_members"); + + builder.Property(m => m.Status).HasConversion().HasMaxLength(50).IsRequired(); + builder.Property(m => m.RequestedAt).IsRequired(); + + builder.HasOne(m => m.CampSeason) + .WithMany() + .HasForeignKey(m => m.CampSeasonId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(m => m.User) + .WithMany() + .HasForeignKey(m => m.UserId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(m => m.ConfirmedByUser) + .WithMany() + .HasForeignKey(m => m.ConfirmedByUserId) + .OnDelete(DeleteBehavior.SetNull); + + builder.HasOne(m => m.RemovedByUser) + .WithMany() + .HasForeignKey(m => m.RemovedByUserId) + .OnDelete(DeleteBehavior.SetNull); + + // Partial unique index: one active-or-pending membership per (season, user). + // Removed rows are allowed to coexist so users can re-request later. + builder.HasIndex(m => new { m.CampSeasonId, m.UserId }) + .HasFilter("\"Status\" <> 'Removed'") + .IsUnique() + .HasDatabaseName("IX_camp_members_active_unique"); + + builder.HasIndex(m => m.UserId); + } +} diff --git a/src/Humans.Infrastructure/Data/HumansDbContext.cs b/src/Humans.Infrastructure/Data/HumansDbContext.cs index fd2af434..824ca28a 100644 --- a/src/Humans.Infrastructure/Data/HumansDbContext.cs +++ b/src/Humans.Infrastructure/Data/HumansDbContext.cs @@ -43,6 +43,7 @@ public HumansDbContext(DbContextOptions options) public DbSet Camps => Set(); public DbSet CampSeasons => Set(); public DbSet CampLeads => Set(); + public DbSet CampMembers => Set(); public DbSet CampHistoricalNames => Set(); public DbSet CampImages => Set(); public DbSet CampSettings => Set(); diff --git a/src/Humans.Infrastructure/Migrations/20260415021854_AddCampMembers.Designer.cs b/src/Humans.Infrastructure/Migrations/20260415021854_AddCampMembers.Designer.cs new file mode 100644 index 00000000..705be831 --- /dev/null +++ b/src/Humans.Infrastructure/Migrations/20260415021854_AddCampMembers.Designer.cs @@ -0,0 +1,4708 @@ +// +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("20260415021854_AddCampMembers")] + partial class AddCampMembers + { + /// + 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.CampMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CampSeasonId") + .HasColumnType("uuid"); + + b.Property("ConfirmedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ConfirmedByUserId") + .HasColumnType("uuid"); + + b.Property("RemovedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RemovedByUserId") + .HasColumnType("uuid"); + + b.Property("RequestedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ConfirmedByUserId"); + + b.HasIndex("RemovedByUserId"); + + b.HasIndex("UserId"); + + b.HasIndex("CampSeasonId", "UserId") + .IsUnique() + .HasDatabaseName("IX_camp_members_active_unique") + .HasFilter("\"Status\" <> 'Removed'"); + + b.ToTable("camp_members", (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("IsPromotedToDirectory") + .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, + IsPromotedToDirectory = 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, + IsPromotedToDirectory = 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, + IsPromotedToDirectory = 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, + IsPromotedToDirectory = 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, + IsPromotedToDirectory = 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, + IsPromotedToDirectory = 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.CampMember", b => + { + b.HasOne("Humans.Domain.Entities.CampSeason", "CampSeason") + .WithMany() + .HasForeignKey("CampSeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Humans.Domain.Entities.User", "ConfirmedByUser") + .WithMany() + .HasForeignKey("ConfirmedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Humans.Domain.Entities.User", "RemovedByUser") + .WithMany() + .HasForeignKey("RemovedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Humans.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CampSeason"); + + b.Navigation("ConfirmedByUser"); + + b.Navigation("RemovedByUser"); + + 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/20260415021854_AddCampMembers.cs b/src/Humans.Infrastructure/Migrations/20260415021854_AddCampMembers.cs new file mode 100644 index 00000000..ff61b902 --- /dev/null +++ b/src/Humans.Infrastructure/Migrations/20260415021854_AddCampMembers.cs @@ -0,0 +1,88 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using NodaTime; + +#nullable disable + +namespace Humans.Infrastructure.Migrations +{ + /// + public partial class AddCampMembers : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "camp_members", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + CampSeasonId = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + Status = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + RequestedAt = table.Column(type: "timestamp with time zone", nullable: false), + ConfirmedAt = table.Column(type: "timestamp with time zone", nullable: true), + ConfirmedByUserId = table.Column(type: "uuid", nullable: true), + RemovedAt = table.Column(type: "timestamp with time zone", nullable: true), + RemovedByUserId = table.Column(type: "uuid", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_camp_members", x => x.Id); + table.ForeignKey( + name: "FK_camp_members_camp_seasons_CampSeasonId", + column: x => x.CampSeasonId, + principalTable: "camp_seasons", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_camp_members_users_ConfirmedByUserId", + column: x => x.ConfirmedByUserId, + principalTable: "users", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_camp_members_users_RemovedByUserId", + column: x => x.RemovedByUserId, + principalTable: "users", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_camp_members_users_UserId", + column: x => x.UserId, + principalTable: "users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_camp_members_active_unique", + table: "camp_members", + columns: new[] { "CampSeasonId", "UserId" }, + unique: true, + filter: "\"Status\" <> 'Removed'"); + + migrationBuilder.CreateIndex( + name: "IX_camp_members_ConfirmedByUserId", + table: "camp_members", + column: "ConfirmedByUserId"); + + migrationBuilder.CreateIndex( + name: "IX_camp_members_RemovedByUserId", + table: "camp_members", + column: "RemovedByUserId"); + + migrationBuilder.CreateIndex( + name: "IX_camp_members_UserId", + table: "camp_members", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "camp_members"); + } + } +} diff --git a/src/Humans.Infrastructure/Migrations/HumansDbContextModelSnapshot.cs b/src/Humans.Infrastructure/Migrations/HumansDbContextModelSnapshot.cs index 81e47394..63b1ab91 100644 --- a/src/Humans.Infrastructure/Migrations/HumansDbContextModelSnapshot.cs +++ b/src/Humans.Infrastructure/Migrations/HumansDbContextModelSnapshot.cs @@ -703,6 +703,54 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("camp_leads", (string)null); }); + modelBuilder.Entity("Humans.Domain.Entities.CampMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CampSeasonId") + .HasColumnType("uuid"); + + b.Property("ConfirmedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ConfirmedByUserId") + .HasColumnType("uuid"); + + b.Property("RemovedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RemovedByUserId") + .HasColumnType("uuid"); + + b.Property("RequestedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ConfirmedByUserId"); + + b.HasIndex("RemovedByUserId"); + + b.HasIndex("UserId"); + + b.HasIndex("CampSeasonId", "UserId") + .IsUnique() + .HasDatabaseName("IX_camp_members_active_unique") + .HasFilter("\"Status\" <> 'Removed'"); + + b.ToTable("camp_members", (string)null); + }); + modelBuilder.Entity("Humans.Domain.Entities.CampPolygon", b => { b.Property("Id") @@ -3754,6 +3802,39 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("Humans.Domain.Entities.CampMember", b => + { + b.HasOne("Humans.Domain.Entities.CampSeason", "CampSeason") + .WithMany() + .HasForeignKey("CampSeasonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Humans.Domain.Entities.User", "ConfirmedByUser") + .WithMany() + .HasForeignKey("ConfirmedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Humans.Domain.Entities.User", "RemovedByUser") + .WithMany() + .HasForeignKey("RemovedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Humans.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CampSeason"); + + b.Navigation("ConfirmedByUser"); + + b.Navigation("RemovedByUser"); + + b.Navigation("User"); + }); + modelBuilder.Entity("Humans.Domain.Entities.CampPolygon", b => { b.HasOne("Humans.Domain.Entities.CampSeason", "CampSeason") diff --git a/src/Humans.Infrastructure/Services/CampService.cs b/src/Humans.Infrastructure/Services/CampService.cs index ae6e35ea..9246f33b 100644 --- a/src/Humans.Infrastructure/Services/CampService.cs +++ b/src/Humans.Infrastructure/Services/CampService.cs @@ -707,6 +707,8 @@ public async Task RejectSeasonAsync(Guid seasonId, Guid reviewedByUserId, string season.ResolvedAt = now; season.UpdatedAt = now; + await AutoWithdrawPendingMembershipsForSeasonAsync(seasonId, now, cancellationToken); + await _auditLogService.LogAsync( AuditAction.CampSeasonRejected, nameof(CampSeason), seasonId, $"Rejected season {season.Year}: {notes}", @@ -728,6 +730,8 @@ public async Task WithdrawSeasonAsync(Guid seasonId, CancellationToken cancellat season.Status = CampSeasonStatus.Withdrawn; season.UpdatedAt = now; + await AutoWithdrawPendingMembershipsForSeasonAsync(seasonId, now, cancellationToken); + await _auditLogService.LogAsync( AuditAction.CampSeasonWithdrawn, nameof(CampSeason), seasonId, $"Withdrew from season {season.Year}", @@ -738,6 +742,20 @@ await _auditLogService.LogAsync( InvalidateCache(season.Year); } + private async Task AutoWithdrawPendingMembershipsForSeasonAsync( + Guid seasonId, Instant now, CancellationToken cancellationToken) + { + var pending = await _dbContext.CampMembers + .Where(m => m.CampSeasonId == seasonId && m.Status == CampMemberStatus.Pending) + .ToListAsync(cancellationToken); + + foreach (var member in pending) + { + member.Status = CampMemberStatus.Removed; + member.RemovedAt = now; + } + } + public async Task SetSeasonFullAsync(Guid seasonId, CancellationToken cancellationToken = default) { var season = await _dbContext.CampSeasons.FindAsync([seasonId], cancellationToken) @@ -1287,4 +1305,273 @@ private static CampSeason CreateSeasonFromData(Guid campId, int year, string nam UpdatedAt = now }; } + + // ========================================================================== + // Camp membership per season + // ========================================================================== + + /// + /// Resolve the "open for membership" season for a camp. Uses the camp's existing + /// season for , and only considers Active or Full + /// seasons eligible (Pending/Rejected/Withdrawn seasons are not open for membership). + /// + private async Task ResolveOpenMembershipSeasonAsync( + Guid campId, CancellationToken cancellationToken) + { + var settings = await GetSettingsAsync(cancellationToken); + return await _dbContext.CampSeasons + .Where(s => s.CampId == campId && s.Year == settings.PublicYear) + .Where(s => s.Status == CampSeasonStatus.Active || s.Status == CampSeasonStatus.Full) + .FirstOrDefaultAsync(cancellationToken); + } + + public async Task RequestCampMembershipAsync( + Guid campId, Guid userId, CancellationToken cancellationToken = default) + { + var season = await ResolveOpenMembershipSeasonAsync(campId, cancellationToken); + if (season is null) + { + return new CampMemberRequestResult(Guid.Empty, CampMemberRequestOutcome.NoOpenSeason, + "Camp is not open for membership this year."); + } + + var existing = await _dbContext.CampMembers + .Where(m => m.CampSeasonId == season.Id && m.UserId == userId && m.Status != CampMemberStatus.Removed) + .FirstOrDefaultAsync(cancellationToken); + + if (existing is not null) + { + return existing.Status switch + { + CampMemberStatus.Pending => new CampMemberRequestResult(existing.Id, CampMemberRequestOutcome.AlreadyPending), + CampMemberStatus.Active => new CampMemberRequestResult(existing.Id, CampMemberRequestOutcome.AlreadyActive), + _ => new CampMemberRequestResult(existing.Id, CampMemberRequestOutcome.AlreadyPending) + }; + } + + var now = _clock.GetCurrentInstant(); + var member = new CampMember + { + Id = Guid.NewGuid(), + CampSeasonId = season.Id, + UserId = userId, + Status = CampMemberStatus.Pending, + RequestedAt = now + }; + + _dbContext.CampMembers.Add(member); + + await _auditLogService.LogAsync( + AuditAction.CampMemberRequested, nameof(CampMember), member.Id, + $"Requested membership in camp season {season.Year}", + userId, + relatedEntityId: campId, relatedEntityType: nameof(Camp)); + + await _dbContext.SaveChangesAsync(cancellationToken); + + return new CampMemberRequestResult(member.Id, CampMemberRequestOutcome.Created); + } + + public async Task ApproveCampMemberAsync( + Guid campMemberId, Guid approvedByUserId, CancellationToken cancellationToken = default) + { + var member = await _dbContext.CampMembers + .Include(m => m.CampSeason) + .FirstOrDefaultAsync(m => m.Id == campMemberId, cancellationToken) + ?? throw new InvalidOperationException("Camp member record not found."); + + if (member.Status != CampMemberStatus.Pending) + throw new InvalidOperationException($"Cannot approve a camp member with status {member.Status}."); + + var now = _clock.GetCurrentInstant(); + member.Status = CampMemberStatus.Active; + member.ConfirmedAt = now; + member.ConfirmedByUserId = approvedByUserId; + + await _auditLogService.LogAsync( + AuditAction.CampMemberApproved, nameof(CampMember), member.Id, + $"Approved camp membership for season {member.CampSeason.Year}", + approvedByUserId, + relatedEntityId: member.CampSeason.CampId, relatedEntityType: nameof(Camp)); + + await _dbContext.SaveChangesAsync(cancellationToken); + } + + public async Task RejectCampMemberAsync( + Guid campMemberId, Guid rejectedByUserId, CancellationToken cancellationToken = default) + { + var member = await _dbContext.CampMembers + .Include(m => m.CampSeason) + .FirstOrDefaultAsync(m => m.Id == campMemberId, cancellationToken) + ?? throw new InvalidOperationException("Camp member record not found."); + + if (member.Status != CampMemberStatus.Pending) + throw new InvalidOperationException($"Cannot reject a camp member with status {member.Status}."); + + var now = _clock.GetCurrentInstant(); + member.Status = CampMemberStatus.Removed; + member.RemovedAt = now; + member.RemovedByUserId = rejectedByUserId; + + await _auditLogService.LogAsync( + AuditAction.CampMemberRejected, nameof(CampMember), member.Id, + $"Rejected camp membership request for season {member.CampSeason.Year}", + rejectedByUserId, + relatedEntityId: member.CampSeason.CampId, relatedEntityType: nameof(Camp)); + + await _dbContext.SaveChangesAsync(cancellationToken); + } + + public async Task RemoveCampMemberAsync( + Guid campMemberId, Guid removedByUserId, CancellationToken cancellationToken = default) + { + var member = await _dbContext.CampMembers + .Include(m => m.CampSeason) + .FirstOrDefaultAsync(m => m.Id == campMemberId, cancellationToken) + ?? throw new InvalidOperationException("Camp member record not found."); + + if (member.Status != CampMemberStatus.Active) + throw new InvalidOperationException($"Cannot remove a camp member with status {member.Status}."); + + var now = _clock.GetCurrentInstant(); + member.Status = CampMemberStatus.Removed; + member.RemovedAt = now; + member.RemovedByUserId = removedByUserId; + + await _auditLogService.LogAsync( + AuditAction.CampMemberRemoved, nameof(CampMember), member.Id, + $"Removed camp member from season {member.CampSeason.Year}", + removedByUserId, + relatedEntityId: member.CampSeason.CampId, relatedEntityType: nameof(Camp)); + + await _dbContext.SaveChangesAsync(cancellationToken); + } + + public async Task WithdrawCampMembershipRequestAsync( + Guid campMemberId, Guid userId, CancellationToken cancellationToken = default) + { + var member = await _dbContext.CampMembers + .Include(m => m.CampSeason) + .FirstOrDefaultAsync(m => m.Id == campMemberId, cancellationToken) + ?? throw new InvalidOperationException("Camp member record not found."); + + if (member.UserId != userId) + throw new InvalidOperationException("You can only withdraw your own camp membership request."); + + if (member.Status != CampMemberStatus.Pending) + throw new InvalidOperationException($"Cannot withdraw a camp member request with status {member.Status}."); + + var now = _clock.GetCurrentInstant(); + member.Status = CampMemberStatus.Removed; + member.RemovedAt = now; + member.RemovedByUserId = userId; + + await _auditLogService.LogAsync( + AuditAction.CampMemberWithdrawn, nameof(CampMember), member.Id, + $"Withdrew camp membership request for season {member.CampSeason.Year}", + userId, + relatedEntityId: member.CampSeason.CampId, relatedEntityType: nameof(Camp)); + + await _dbContext.SaveChangesAsync(cancellationToken); + } + + public async Task LeaveCampAsync( + Guid campMemberId, Guid userId, CancellationToken cancellationToken = default) + { + var member = await _dbContext.CampMembers + .Include(m => m.CampSeason) + .FirstOrDefaultAsync(m => m.Id == campMemberId, cancellationToken) + ?? throw new InvalidOperationException("Camp member record not found."); + + if (member.UserId != userId) + throw new InvalidOperationException("You can only leave your own camp membership."); + + if (member.Status != CampMemberStatus.Active) + throw new InvalidOperationException($"Cannot leave a camp membership with status {member.Status}."); + + var now = _clock.GetCurrentInstant(); + member.Status = CampMemberStatus.Removed; + member.RemovedAt = now; + member.RemovedByUserId = userId; + + await _auditLogService.LogAsync( + AuditAction.CampMemberLeft, nameof(CampMember), member.Id, + $"Left camp season {member.CampSeason.Year}", + userId, + relatedEntityId: member.CampSeason.CampId, relatedEntityType: nameof(Camp)); + + await _dbContext.SaveChangesAsync(cancellationToken); + } + + public async Task GetMembershipStateForCampAsync( + Guid campId, Guid userId, CancellationToken cancellationToken = default) + { + var season = await ResolveOpenMembershipSeasonAsync(campId, cancellationToken); + if (season is null) + { + return new CampMembershipState(null, null, null, CampMemberStatusSummary.NoOpenSeason); + } + + var member = await _dbContext.CampMembers + .Where(m => m.CampSeasonId == season.Id && m.UserId == userId && m.Status != CampMemberStatus.Removed) + .FirstOrDefaultAsync(cancellationToken); + + if (member is null) + { + return new CampMembershipState(season.Year, season.Id, null, CampMemberStatusSummary.None); + } + + var summary = member.Status == CampMemberStatus.Active + ? CampMemberStatusSummary.Active + : CampMemberStatusSummary.Pending; + + return new CampMembershipState(season.Year, season.Id, member.Id, summary); + } + + public async Task GetCampMembersAsync( + Guid campSeasonId, CancellationToken cancellationToken = default) + { + var season = await _dbContext.CampSeasons.FindAsync([campSeasonId], cancellationToken) + ?? throw new InvalidOperationException("Season not found."); + + var members = await _dbContext.CampMembers + .Include(m => m.User) + .Where(m => m.CampSeasonId == campSeasonId && m.Status != CampMemberStatus.Removed) + .OrderBy(m => m.RequestedAt) + .ToListAsync(cancellationToken); + + var pending = members + .Where(m => m.Status == CampMemberStatus.Pending) + .Select(m => new CampMemberRow(m.Id, m.UserId, m.User.DisplayName, m.RequestedAt, m.ConfirmedAt)) + .ToList(); + + var active = members + .Where(m => m.Status == CampMemberStatus.Active) + .Select(m => new CampMemberRow(m.Id, m.UserId, m.User.DisplayName, m.RequestedAt, m.ConfirmedAt)) + .ToList(); + + return new CampMemberListData(campSeasonId, season.Year, pending, active); + } + + public async Task> GetCampMembershipsForUserAsync( + Guid userId, CancellationToken cancellationToken = default) + { + return await _dbContext.CampMembers + .Include(m => m.CampSeason) + .ThenInclude(s => s.Camp) + .Where(m => m.UserId == userId && m.Status != CampMemberStatus.Removed) + .OrderByDescending(m => m.CampSeason.Year) + .ThenBy(m => m.CampSeason.Name) + .Select(m => new CampMembershipSummary( + m.Id, + m.CampSeason.CampId, + m.CampSeason.Camp.Slug, + m.CampSeason.Name, + m.CampSeasonId, + m.CampSeason.Year, + m.Status, + m.RequestedAt, + m.ConfirmedAt)) + .ToListAsync(cancellationToken); + } } diff --git a/src/Humans.Web/Controllers/CampController.cs b/src/Humans.Web/Controllers/CampController.cs index 06ce57fb..b12eec1a 100644 --- a/src/Humans.Web/Controllers/CampController.cs +++ b/src/Humans.Web/Controllers/CampController.cs @@ -116,8 +116,9 @@ public async Task Details(string slug) return NotFound(); var (isLead, isCampAdmin) = await ResolveCampViewerStateAsync(camp); + var membership = await ResolveCurrentUserMembershipStateAsync(camp.Id); - return View(MapCampDetailViewModel(campDetail, isLead, isCampAdmin)); + return View(MapCampDetailViewModel(campDetail, isLead, isCampAdmin, membership)); } [AllowAnonymous] @@ -136,8 +137,38 @@ public async Task SeasonDetails(string slug, int year) return NotFound(); var (isLead, isCampAdmin) = await ResolveCampViewerStateAsync(camp); + var membership = await ResolveCurrentUserMembershipStateAsync(camp.Id); - return View(nameof(Details), MapCampDetailViewModel(campDetail, isLead, isCampAdmin)); + return View(nameof(Details), MapCampDetailViewModel(campDetail, isLead, isCampAdmin, membership)); + } + + private async Task ResolveCurrentUserMembershipStateAsync(Guid campId) + { + if (User.Identity?.IsAuthenticated != true) + { + return new CampMembershipStateViewModel { Status = CampMemberStatusSummaryView.NoOpenSeason }; + } + + var user = await GetCurrentUserAsync(); + if (user is null) + { + return new CampMembershipStateViewModel { Status = CampMemberStatusSummaryView.NoOpenSeason }; + } + + var state = await _campService.GetMembershipStateForCampAsync(campId, user.Id); + var status = state.Status switch + { + CampMemberStatusSummary.Active => CampMemberStatusSummaryView.Active, + CampMemberStatusSummary.Pending => CampMemberStatusSummaryView.Pending, + CampMemberStatusSummary.None => CampMemberStatusSummaryView.None, + _ => CampMemberStatusSummaryView.NoOpenSeason + }; + return new CampMembershipStateViewModel + { + OpenSeasonYear = state.OpenSeasonYear, + CampMemberId = state.CampMemberId, + Status = status + }; } // ====================================================================== @@ -343,7 +374,39 @@ public async Task Edit(string slug, int? year) return RedirectToAction(nameof(Details), new { slug }); } - return View(MapToEditViewModel(editData)); + var viewModel = MapToEditViewModel(editData); + await PopulateEditMembersAsync(viewModel); + return View(viewModel); + } + + private async Task PopulateEditMembersAsync(CampEditViewModel viewModel) + { + if (viewModel.SeasonId == Guid.Empty) + { + return; + } + + var members = await _campService.GetCampMembersAsync(viewModel.SeasonId); + viewModel.PendingMembers = members.Pending + .Select(m => new CampMemberRowViewModel + { + CampMemberId = m.CampMemberId, + UserId = m.UserId, + DisplayName = m.DisplayName, + RequestedAt = m.RequestedAt, + ConfirmedAt = m.ConfirmedAt + }) + .ToList(); + viewModel.ActiveMembers = members.Active + .Select(m => new CampMemberRowViewModel + { + CampMemberId = m.CampMemberId, + UserId = m.UserId, + DisplayName = m.DisplayName, + RequestedAt = m.RequestedAt, + ConfirmedAt = m.ConfirmedAt + }) + .ToList(); } [Authorize] @@ -691,6 +754,229 @@ public async Task ReorderImages(string slug, List imageIds) return RedirectToAction(nameof(Edit), new { slug }); } + // ====================================================================== + // Camp membership per season (issue #488) + // ====================================================================== + + [Authorize] + [HttpPost("{slug}/Members/Request")] + [ValidateAntiForgeryToken] + public async Task RequestMembership(string slug) + { + var camp = await GetCampBySlugAsync(slug); + if (camp is null) return NotFound(); + + var (currentUserError, user) = await ResolveCurrentUserOrUnauthorizedAsync(); + if (currentUserError is not null) return currentUserError; + + try + { + var result = await _campService.RequestCampMembershipAsync(camp.Id, user.Id); + switch (result.Outcome) + { + case CampMemberRequestOutcome.Created: + SetSuccess("Your request to join has been sent to the camp leads."); + break; + case CampMemberRequestOutcome.AlreadyPending: + SetInfo("You already have a pending request for this camp."); + break; + case CampMemberRequestOutcome.AlreadyActive: + SetInfo("You are already an active member of this camp."); + break; + case CampMemberRequestOutcome.NoOpenSeason: + SetError(result.Message ?? "Camp is not open for membership this year."); + break; + } + } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, "Camp membership request failed for camp {CampId} and user {UserId}", camp.Id, user.Id); + SetError(ex.Message); + } + + return RedirectToAction(nameof(Details), new { slug }); + } + + [Authorize] + [HttpPost("{slug}/Members/Withdraw/{campMemberId:guid}")] + [ValidateAntiForgeryToken] + public async Task WithdrawMembershipRequest(string slug, Guid campMemberId) + { + var camp = await GetCampBySlugAsync(slug); + if (camp is null) return NotFound(); + + var (currentUserError, user) = await ResolveCurrentUserOrUnauthorizedAsync(); + if (currentUserError is not null) return currentUserError; + + try + { + await _campService.WithdrawCampMembershipRequestAsync(campMemberId, user.Id); + SetSuccess("Your pending request was withdrawn."); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, "Withdraw camp membership request failed for member {MemberId} and user {UserId}", campMemberId, user.Id); + SetError(ex.Message); + } + + return RedirectToAction(nameof(Details), new { slug }); + } + + [Authorize] + [HttpPost("{slug}/Members/Leave/{campMemberId:guid}")] + [ValidateAntiForgeryToken] + public async Task LeaveMembership(string slug, Guid campMemberId) + { + var camp = await GetCampBySlugAsync(slug); + if (camp is null) return NotFound(); + + var (currentUserError, user) = await ResolveCurrentUserOrUnauthorizedAsync(); + if (currentUserError is not null) return currentUserError; + + try + { + await _campService.LeaveCampAsync(campMemberId, user.Id); + SetSuccess("You have left this camp for this season."); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, "Leave camp failed for member {MemberId} and user {UserId}", campMemberId, user.Id); + SetError(ex.Message); + } + + return RedirectToAction(nameof(Details), new { slug }); + } + + [Authorize] + [HttpPost("{slug}/Members/Approve/{campMemberId:guid}")] + [ValidateAntiForgeryToken] + public async Task ApproveMembership(string slug, Guid campMemberId) + { + var (errorResult, user, camp) = await ResolveCampManagementAsync(slug); + if (errorResult is not null) return errorResult; + + try + { + await _campService.ApproveCampMemberAsync(campMemberId, user.Id); + + var member = await _campService.GetCampMembersAsync(await ResolveOpenSeasonIdForCampAsync(camp.Id)); + var row = member.Active.FirstOrDefault(r => r.CampMemberId == campMemberId) + ?? member.Pending.FirstOrDefault(r => r.CampMemberId == campMemberId); + + // Notify the requester (best-effort) + try + { + var campName = camp.Seasons.OrderByDescending(s => s.Year).FirstOrDefault()?.Name ?? camp.Slug; + if (row is not null) + { + await _notificationService.SendAsync( + NotificationSource.CampMembershipApproved, + NotificationClass.Informational, + NotificationPriority.Normal, + $"Your request to join {campName} was approved", + [row.UserId], + actionUrl: $"/Camps/{slug}", + actionLabel: "View camp"); + } + } + catch (Exception notifEx) + { + _logger.LogError(notifEx, "Failed to notify requester about approved camp membership {MemberId}", campMemberId); + } + + SetSuccess("Membership approved."); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, "Approve camp membership failed for member {MemberId} and camp {CampId}", campMemberId, camp.Id); + SetError(ex.Message); + } + + return RedirectToAction(nameof(Edit), new { slug }); + } + + [Authorize] + [HttpPost("{slug}/Members/Reject/{campMemberId:guid}")] + [ValidateAntiForgeryToken] + public async Task RejectMembership(string slug, Guid campMemberId) + { + var (errorResult, user, camp) = await ResolveCampManagementAsync(slug); + if (errorResult is not null) return errorResult; + + // Capture the requester BEFORE rejecting (after rejection the row status is Removed). + var seasonId = await ResolveOpenSeasonIdForCampAsync(camp.Id); + Guid? requesterUserId = null; + if (seasonId != Guid.Empty) + { + var list = await _campService.GetCampMembersAsync(seasonId); + requesterUserId = list.Pending.FirstOrDefault(r => r.CampMemberId == campMemberId)?.UserId; + } + + try + { + await _campService.RejectCampMemberAsync(campMemberId, user.Id); + + try + { + if (requesterUserId.HasValue) + { + var campName = camp.Seasons.OrderByDescending(s => s.Year).FirstOrDefault()?.Name ?? camp.Slug; + await _notificationService.SendAsync( + NotificationSource.CampMembershipRejected, + NotificationClass.Informational, + NotificationPriority.Normal, + $"Your request to join {campName} was not approved", + [requesterUserId.Value]); + } + } + catch (Exception notifEx) + { + _logger.LogError(notifEx, "Failed to notify requester about rejected camp membership {MemberId}", campMemberId); + } + + SetSuccess("Request rejected."); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, "Reject camp membership failed for member {MemberId} and camp {CampId}", campMemberId, camp.Id); + SetError(ex.Message); + } + + return RedirectToAction(nameof(Edit), new { slug }); + } + + [Authorize] + [HttpPost("{slug}/Members/Remove/{campMemberId:guid}")] + [ValidateAntiForgeryToken] + public async Task RemoveMembership(string slug, Guid campMemberId) + { + var (errorResult, user, camp) = await ResolveCampManagementAsync(slug); + if (errorResult is not null) return errorResult; + + try + { + await _campService.RemoveCampMemberAsync(campMemberId, user.Id); + SetSuccess("Member removed."); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, "Remove camp member failed for member {MemberId} and camp {CampId}", campMemberId, camp.Id); + SetError(ex.Message); + } + + return RedirectToAction(nameof(Edit), new { slug }); + } + + private async Task ResolveOpenSeasonIdForCampAsync(Guid campId) + { + var settings = await _campService.GetSettingsAsync(); + var camp = await _campService.GetCampByIdAsync(campId); + var season = camp?.Seasons + .FirstOrDefault(s => s.Year == settings.PublicYear + && (s.Status == CampSeasonStatus.Active || s.Status == CampSeasonStatus.Full)); + return season?.Id ?? Guid.Empty; + } + // ====================================================================== // Helper methods // ====================================================================== @@ -743,6 +1029,7 @@ private async Task PopulateEditReadOnlyFieldsAsync(CampEditViewModel model) SortOrder = image.SortOrder }) .ToList(); + await PopulateEditMembersAsync(model); } private static CampEditViewModel MapToEditViewModel(CampEditData editData) => @@ -804,7 +1091,8 @@ private static CampEditViewModel MapToEditViewModel(CampEditData editData) => private static CampDetailViewModel MapCampDetailViewModel( CampDetailData campDetail, bool isLead, - bool isCampAdmin) => new() + bool isCampAdmin, + CampMembershipStateViewModel membership) => new() { Id = campDetail.Id, Slug = campDetail.Slug, @@ -850,7 +1138,8 @@ private static CampDetailViewModel MapCampDetailViewModel( IsNameLocked = campDetail.CurrentSeason.IsNameLocked }, IsCurrentUserLead = isLead, - IsCurrentUserCampAdmin = isCampAdmin + IsCurrentUserCampAdmin = isCampAdmin, + Membership = membership }; private void ValidatePhoneE164(string? phone, string fieldName) diff --git a/src/Humans.Web/Models/CampViewModels.cs b/src/Humans.Web/Models/CampViewModels.cs index d3031ab6..ef0ba52b 100644 --- a/src/Humans.Web/Models/CampViewModels.cs +++ b/src/Humans.Web/Models/CampViewModels.cs @@ -53,6 +53,25 @@ public class CampDetailViewModel public CampSeasonDetailViewModel? CurrentSeason { get; set; } public bool IsCurrentUserLead { get; set; } public bool IsCurrentUserCampAdmin { get; set; } + public CampMembershipStateViewModel Membership { get; set; } = new(); +} + +/// +/// Represents the current authenticated user's relationship to a camp for the open season. +/// +public class CampMembershipStateViewModel +{ + public int? OpenSeasonYear { get; set; } + public Guid? CampMemberId { get; set; } + public CampMemberStatusSummaryView Status { get; set; } = CampMemberStatusSummaryView.NoOpenSeason; +} + +public enum CampMemberStatusSummaryView +{ + NoOpenSeason, + None, + Pending, + Active } public class CampSeasonDetailViewModel @@ -129,6 +148,17 @@ public class CampEditViewModel : CampRegisterViewModel public List Leads { get; set; } = new(); public List Images { get; set; } = new(); public List ExistingHistoricalNames { get; set; } = new(); + public List PendingMembers { get; set; } = new(); + public List ActiveMembers { get; set; } = new(); +} + +public class CampMemberRowViewModel +{ + public Guid CampMemberId { get; set; } + public Guid UserId { get; set; } + public string DisplayName { get; set; } = string.Empty; + public NodaTime.Instant RequestedAt { get; set; } + public NodaTime.Instant? ConfirmedAt { get; set; } } public class CampImageViewModel diff --git a/src/Humans.Web/ViewComponents/MyCampsViewComponent.cs b/src/Humans.Web/ViewComponents/MyCampsViewComponent.cs new file mode 100644 index 00000000..f29fc13c --- /dev/null +++ b/src/Humans.Web/ViewComponents/MyCampsViewComponent.cs @@ -0,0 +1,82 @@ +using Humans.Application.Interfaces; +using Humans.Domain.Entities; +using Humans.Domain.Enums; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; + +namespace Humans.Web.ViewComponents; + +/// +/// Lists camps the current human belongs to (or has requested), grouped by year. +/// Rendered on the human's own profile page. Private — never shown publicly. +/// +public class MyCampsViewComponent : ViewComponent +{ + private readonly ICampService _campService; + private readonly UserManager _userManager; + private readonly ILogger _logger; + + public MyCampsViewComponent( + ICampService campService, + UserManager userManager, + ILogger logger) + { + _campService = campService; + _userManager = userManager; + _logger = logger; + } + + public async Task InvokeAsync() + { + try + { + var user = await _userManager.GetUserAsync(UserClaimsPrincipal); + if (user is null) + return Content(string.Empty); + + var memberships = await _campService.GetCampMembershipsForUserAsync(user.Id); + if (memberships.Count == 0) + return Content(string.Empty); + + var byYear = memberships + .GroupBy(m => m.Year) + .OrderByDescending(g => g.Key) + .Select(g => new MyCampsYearGroup + { + Year = g.Key, + Memberships = g.Select(m => new MyCampsMembership + { + CampSlug = m.CampSlug, + CampName = m.CampName, + Status = m.Status + }).ToList() + }) + .ToList(); + + return View(new MyCampsViewModel { Years = byYear }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load camp memberships for current user"); + return Content(string.Empty); + } + } +} + +public class MyCampsViewModel +{ + public List Years { get; set; } = []; +} + +public class MyCampsYearGroup +{ + public int Year { get; set; } + public List Memberships { get; set; } = []; +} + +public class MyCampsMembership +{ + public string CampSlug { get; set; } = string.Empty; + public string CampName { get; set; } = string.Empty; + public CampMemberStatus Status { get; set; } +} diff --git a/src/Humans.Web/Views/Camp/Details.cshtml b/src/Humans.Web/Views/Camp/Details.cshtml index ed87817b..1b4da79e 100644 --- a/src/Humans.Web/Views/Camp/Details.cshtml +++ b/src/Humans.Web/Views/Camp/Details.cshtml @@ -263,6 +263,70 @@ + @* Camp membership (authenticated humans only, never public) *@ + @if (User.Identity?.IsAuthenticated == true) + { +
+
+ Join this @Localizer["Camp_Singular"] +
+
+

+ Each camp manages its own membership through its own process (website, spreadsheet, WhatsApp). + This button does not join you to the camp. Use it to tell Humans about it once + you are already in, so the system knows and can set up roles and early-entry slots. +

+ @switch (Model.Membership.Status) + { + case CampMemberStatusSummaryView.Active: +
+ + You are a member of this camp for @Model.Membership.OpenSeasonYear. +
+ @if (Model.Membership.CampMemberId.HasValue) + { +
+ @Html.AntiForgeryToken() + +
+ } + break; + case CampMemberStatusSummaryView.Pending: +
+ Request pending — a camp lead will confirm it soon. +
+ @if (Model.Membership.CampMemberId.HasValue) + { +
+ @Html.AntiForgeryToken() + +
+ } + break; + case CampMemberStatusSummaryView.None: +
+ @Html.AntiForgeryToken() + +
+ break; + case CampMemberStatusSummaryView.NoOpenSeason: + default: + + break; + } +
+
+ } + @* Times participating *@ @if (Model.TimesAtNowhere > 0) { diff --git a/src/Humans.Web/Views/Camp/Edit.cshtml b/src/Humans.Web/Views/Camp/Edit.cshtml index 13c06eac..6b1bcc41 100644 --- a/src/Humans.Web/Views/Camp/Edit.cshtml +++ b/src/Humans.Web/Views/Camp/Edit.cshtml @@ -286,6 +286,84 @@ + @* Camp Members (issue #488) *@ +
+
+ Humans in this @Localizer["Camp_Singular"] (@Model.Year) +
+
+

+ This is a post-hoc list — humans join through the @Localizer["Camp_Singular"].@("'")s own process, + then come here to tell Humans. Only camp leads and CampAdmin see this list. +

+
+ @if (Model.PendingMembers.Count > 0) + { +
+ Pending requests (@Model.PendingMembers.Count) +
+
    + @foreach (var member in Model.PendingMembers) + { +
  • +
    + +
    + Requested @member.RequestedAt.ToDateTimeUtc().ToString("d MMM yyyy") +
    +
    +
    + @Html.AntiForgeryToken() + +
    +
    + @Html.AntiForgeryToken() + +
    +
    +
  • + } +
+ } +
+ Active members (@Model.ActiveMembers.Count) +
+ @if (Model.ActiveMembers.Count > 0) + { +
    + @foreach (var member in Model.ActiveMembers) + { +
  • +
    + + @if (member.ConfirmedAt.HasValue) + { +
    + Confirmed @member.ConfirmedAt.Value.ToDateTimeUtc().ToString("d MMM yyyy") + } +
    +
    + @Html.AntiForgeryToken() + +
    +
  • + } +
+ } + else + { +
+ No active members yet. +
+ } +
+ @* Image Management *@
diff --git a/src/Humans.Web/Views/Profile/Index.cshtml b/src/Humans.Web/Views/Profile/Index.cshtml index b2315114..66c51833 100644 --- a/src/Humans.Web/Views/Profile/Index.cshtml +++ b/src/Humans.Web/Views/Profile/Index.cshtml @@ -100,6 +100,11 @@
} + @if (Model.IsOwnProfile) + { + @await Component.InvokeAsync("MyCamps") + } + @if (Model.IsOwnProfile && Model.CampaignGrants.Any()) {
diff --git a/src/Humans.Web/Views/Shared/Components/MyCamps/Default.cshtml b/src/Humans.Web/Views/Shared/Components/MyCamps/Default.cshtml new file mode 100644 index 00000000..dacf309e --- /dev/null +++ b/src/Humans.Web/Views/Shared/Components/MyCamps/Default.cshtml @@ -0,0 +1,35 @@ +@using Humans.Domain.Enums +@model Humans.Web.ViewComponents.MyCampsViewModel + +
+
+
My @Localizer["Camp_Plural"]
+
+
+ @foreach (var group in Model.Years) + { +
+ @group.Year +
+
    + @foreach (var membership in group.Memberships) + { +
  • + + @membership.CampName + + @switch (membership.Status) + { + case CampMemberStatus.Active: + Active + break; + case CampMemberStatus.Pending: + Pending + break; + } +
  • + } +
+ } +
+
diff --git a/tests/Humans.Application.Tests/Services/CampServiceTests.cs b/tests/Humans.Application.Tests/Services/CampServiceTests.cs index b0c29c06..ec9bf97a 100644 --- a/tests/Humans.Application.Tests/Services/CampServiceTests.cs +++ b/tests/Humans.Application.Tests/Services/CampServiceTests.cs @@ -568,6 +568,270 @@ public async Task ChangeSeasonNameAsync_AfterLockDate_Throws() await act.Should().ThrowAsync().WithMessage("*locked*"); } + // ========================================================================== + // Camp membership per season (issue #488) + // ========================================================================== + + [Fact] + public async Task RequestCampMembershipAsync_OpenSeason_CreatesPending() + { + await SeedSettingsAsync(); + var camp = await CreateTestCamp(); + await ApproveLatestSeasonAsync(camp.Id); + var userId = Guid.NewGuid(); + await SeedUserAsync(userId, "Alice"); + + var result = await _service.RequestCampMembershipAsync(camp.Id, userId); + + result.Outcome.Should().Be(CampMemberRequestOutcome.Created); + var member = await _dbContext.CampMembers.FirstAsync(m => m.Id == result.CampMemberId); + member.Status.Should().Be(CampMemberStatus.Pending); + member.UserId.Should().Be(userId); + } + + [Fact] + public async Task RequestCampMembershipAsync_NoOpenSeason_ReturnsNoOpenSeason() + { + await SeedSettingsAsync(); + var camp = await CreateTestCamp(); + // Season is still Pending — not open for membership + var userId = Guid.NewGuid(); + await SeedUserAsync(userId, "Alice"); + + var result = await _service.RequestCampMembershipAsync(camp.Id, userId); + + result.Outcome.Should().Be(CampMemberRequestOutcome.NoOpenSeason); + (await _dbContext.CampMembers.AnyAsync()).Should().BeFalse(); + } + + [Fact] + public async Task RequestCampMembershipAsync_AlreadyPending_IsIdempotent() + { + await SeedSettingsAsync(); + var camp = await CreateTestCamp(); + await ApproveLatestSeasonAsync(camp.Id); + var userId = Guid.NewGuid(); + await SeedUserAsync(userId, "Alice"); + + var first = await _service.RequestCampMembershipAsync(camp.Id, userId); + var second = await _service.RequestCampMembershipAsync(camp.Id, userId); + + second.Outcome.Should().Be(CampMemberRequestOutcome.AlreadyPending); + second.CampMemberId.Should().Be(first.CampMemberId); + (await _dbContext.CampMembers.CountAsync(m => m.CampSeasonId != Guid.Empty)).Should().Be(1); + } + + [Fact] + public async Task ApproveCampMemberAsync_PendingRequest_SetsActive() + { + await SeedSettingsAsync(); + var camp = await CreateTestCamp(); + await ApproveLatestSeasonAsync(camp.Id); + var userId = Guid.NewGuid(); + await SeedUserAsync(userId, "Alice"); + var request = await _service.RequestCampMembershipAsync(camp.Id, userId); + var approverId = Guid.NewGuid(); + + await _service.ApproveCampMemberAsync(request.CampMemberId, approverId); + + var member = await _dbContext.CampMembers.FindAsync(request.CampMemberId); + member!.Status.Should().Be(CampMemberStatus.Active); + member.ConfirmedByUserId.Should().Be(approverId); + member.ConfirmedAt.Should().NotBeNull(); + } + + [Fact] + public async Task RejectCampMemberAsync_PendingRequest_SetsRemoved() + { + await SeedSettingsAsync(); + var camp = await CreateTestCamp(); + await ApproveLatestSeasonAsync(camp.Id); + var userId = Guid.NewGuid(); + await SeedUserAsync(userId, "Alice"); + var request = await _service.RequestCampMembershipAsync(camp.Id, userId); + + await _service.RejectCampMemberAsync(request.CampMemberId, Guid.NewGuid()); + + var member = await _dbContext.CampMembers.FindAsync(request.CampMemberId); + member!.Status.Should().Be(CampMemberStatus.Removed); + member.RemovedAt.Should().NotBeNull(); + } + + [Fact] + public async Task RejectCampMemberAsync_AllowsReRequest() + { + await SeedSettingsAsync(); + var camp = await CreateTestCamp(); + await ApproveLatestSeasonAsync(camp.Id); + var userId = Guid.NewGuid(); + await SeedUserAsync(userId, "Alice"); + var first = await _service.RequestCampMembershipAsync(camp.Id, userId); + await _service.RejectCampMemberAsync(first.CampMemberId, Guid.NewGuid()); + + var second = await _service.RequestCampMembershipAsync(camp.Id, userId); + + second.Outcome.Should().Be(CampMemberRequestOutcome.Created); + second.CampMemberId.Should().NotBe(first.CampMemberId); + } + + [Fact] + public async Task WithdrawCampMembershipRequestAsync_Self_SetsRemoved() + { + await SeedSettingsAsync(); + var camp = await CreateTestCamp(); + await ApproveLatestSeasonAsync(camp.Id); + var userId = Guid.NewGuid(); + await SeedUserAsync(userId, "Alice"); + var request = await _service.RequestCampMembershipAsync(camp.Id, userId); + + await _service.WithdrawCampMembershipRequestAsync(request.CampMemberId, userId); + + var member = await _dbContext.CampMembers.FindAsync(request.CampMemberId); + member!.Status.Should().Be(CampMemberStatus.Removed); + } + + [Fact] + public async Task WithdrawCampMembershipRequestAsync_OtherUser_Throws() + { + await SeedSettingsAsync(); + var camp = await CreateTestCamp(); + await ApproveLatestSeasonAsync(camp.Id); + var userId = Guid.NewGuid(); + await SeedUserAsync(userId, "Alice"); + var request = await _service.RequestCampMembershipAsync(camp.Id, userId); + + var act = () => _service.WithdrawCampMembershipRequestAsync(request.CampMemberId, Guid.NewGuid()); + await act.Should().ThrowAsync().WithMessage("*only withdraw your own*"); + } + + [Fact] + public async Task LeaveCampAsync_ActiveMember_SetsRemoved() + { + await SeedSettingsAsync(); + var camp = await CreateTestCamp(); + await ApproveLatestSeasonAsync(camp.Id); + var userId = Guid.NewGuid(); + await SeedUserAsync(userId, "Alice"); + var request = await _service.RequestCampMembershipAsync(camp.Id, userId); + await _service.ApproveCampMemberAsync(request.CampMemberId, Guid.NewGuid()); + + await _service.LeaveCampAsync(request.CampMemberId, userId); + + var member = await _dbContext.CampMembers.FindAsync(request.CampMemberId); + member!.Status.Should().Be(CampMemberStatus.Removed); + } + + [Fact] + public async Task RemoveCampMemberAsync_ActiveMember_SetsRemoved() + { + await SeedSettingsAsync(); + var camp = await CreateTestCamp(); + await ApproveLatestSeasonAsync(camp.Id); + var userId = Guid.NewGuid(); + await SeedUserAsync(userId, "Alice"); + var request = await _service.RequestCampMembershipAsync(camp.Id, userId); + await _service.ApproveCampMemberAsync(request.CampMemberId, Guid.NewGuid()); + + await _service.RemoveCampMemberAsync(request.CampMemberId, Guid.NewGuid()); + + var member = await _dbContext.CampMembers.FindAsync(request.CampMemberId); + member!.Status.Should().Be(CampMemberStatus.Removed); + } + + [Fact] + public async Task WithdrawSeasonAsync_AutoWithdrawsPendingMemberships() + { + await SeedSettingsAsync(); + var camp = await CreateTestCamp(); + await ApproveLatestSeasonAsync(camp.Id); + var userId = Guid.NewGuid(); + await SeedUserAsync(userId, "Alice"); + var request = await _service.RequestCampMembershipAsync(camp.Id, userId); + var season = await _dbContext.CampSeasons.FirstAsync(s => s.CampId == camp.Id); + + await _service.WithdrawSeasonAsync(season.Id); + + var member = await _dbContext.CampMembers.FindAsync(request.CampMemberId); + member!.Status.Should().Be(CampMemberStatus.Removed); + } + + [Fact] + public async Task RejectSeasonAsync_AutoWithdrawsPendingMemberships() + { + await SeedSettingsAsync(); + var camp = await CreateTestCamp(); + // Season still Pending. Open a second open-season for membership path: + // To test, promote the current season to Active so we can register a membership, + // then set it back to Pending. Simpler: override the RequestCampMembership to + // inject a pending member via DbContext directly. + var season = await _dbContext.CampSeasons.FirstAsync(s => s.CampId == camp.Id); + // Bypass the open-season check by seeding a CampMember directly. + var userId = Guid.NewGuid(); + await SeedUserAsync(userId, "Alice"); + var memberId = Guid.NewGuid(); + _dbContext.CampMembers.Add(new CampMember + { + Id = memberId, + CampSeasonId = season.Id, + UserId = userId, + Status = CampMemberStatus.Pending, + RequestedAt = _clock.GetCurrentInstant() + }); + await _dbContext.SaveChangesAsync(); + + await _service.RejectSeasonAsync(season.Id, Guid.NewGuid(), "Not ready"); + + var member = await _dbContext.CampMembers.FindAsync(memberId); + member!.Status.Should().Be(CampMemberStatus.Removed); + } + + [Fact] + public async Task GetMembershipStateForCampAsync_ActiveMember_ReturnsActive() + { + await SeedSettingsAsync(); + var camp = await CreateTestCamp(); + await ApproveLatestSeasonAsync(camp.Id); + var userId = Guid.NewGuid(); + await SeedUserAsync(userId, "Alice"); + var request = await _service.RequestCampMembershipAsync(camp.Id, userId); + await _service.ApproveCampMemberAsync(request.CampMemberId, Guid.NewGuid()); + + var state = await _service.GetMembershipStateForCampAsync(camp.Id, userId); + + state.Status.Should().Be(CampMemberStatusSummary.Active); + state.OpenSeasonYear.Should().Be(2026); + } + + [Fact] + public async Task GetMembershipStateForCampAsync_NoSeason_ReturnsNoOpenSeason() + { + await SeedSettingsAsync(); + var camp = await CreateTestCamp(); + // Season is still Pending — not open for membership + + var state = await _service.GetMembershipStateForCampAsync(camp.Id, Guid.NewGuid()); + + state.Status.Should().Be(CampMemberStatusSummary.NoOpenSeason); + } + + [Fact] + public async Task GetCampMembershipsForUserAsync_MultipleYears_GroupsByYear() + { + await SeedSettingsAsync(); + var camp = await CreateTestCamp(); + await ApproveLatestSeasonAsync(camp.Id); + var userId = Guid.NewGuid(); + await SeedUserAsync(userId, "Alice"); + var req = await _service.RequestCampMembershipAsync(camp.Id, userId); + await _service.ApproveCampMemberAsync(req.CampMemberId, Guid.NewGuid()); + + var memberships = await _service.GetCampMembershipsForUserAsync(userId); + + memberships.Should().HaveCount(1); + memberships[0].Status.Should().Be(CampMemberStatus.Active); + memberships[0].Year.Should().Be(2026); + } + // ========================================================================== // Helpers // ==========================================================================