Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions docs/features/20-camps.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 |

Expand Down
15 changes: 11 additions & 4 deletions docs/sections/Camps.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,16 @@
- 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

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

Expand All @@ -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

Expand All @@ -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

Expand Down
126 changes: 126 additions & 0 deletions src/Humans.Application/Interfaces/ICampService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ======================================================================

/// <summary>
/// Human requests to join a camp for the currently-open season.
/// Creates a <see cref="CampMember"/> in Pending status. No-op if an active or pending
/// record already exists for the human in that season.
/// </summary>
Task<CampMemberRequestResult> RequestCampMembershipAsync(
Guid campId, Guid userId, CancellationToken cancellationToken = default);

/// <summary>
/// Approve a pending camp membership request. Sets status to Active.
/// </summary>
Task ApproveCampMemberAsync(
Guid campMemberId, Guid approvedByUserId, CancellationToken cancellationToken = default);

/// <summary>
/// Reject a pending camp membership request. Sets status to Removed.
/// </summary>
Task RejectCampMemberAsync(
Guid campMemberId, Guid rejectedByUserId, CancellationToken cancellationToken = default);

/// <summary>
/// Lead removes an active camp member. Sets status to Removed.
/// </summary>
Task RemoveCampMemberAsync(
Guid campMemberId, Guid removedByUserId, CancellationToken cancellationToken = default);

/// <summary>
/// Human withdraws their own pending request. Sets status to Removed.
/// </summary>
Task WithdrawCampMembershipRequestAsync(
Guid campMemberId, Guid userId, CancellationToken cancellationToken = default);

/// <summary>
/// Human leaves an active membership. Sets status to Removed.
/// </summary>
Task LeaveCampAsync(
Guid campMemberId, Guid userId, CancellationToken cancellationToken = default);

/// <summary>
/// Gets the current user's membership state for a camp's open-season (or null if no open season).
/// </summary>
Task<CampMembershipState> GetMembershipStateForCampAsync(
Guid campId, Guid userId, CancellationToken cancellationToken = default);

/// <summary>
/// Lists members (pending + active) for a given camp season. Privileged view.
/// </summary>
Task<CampMemberListData> GetCampMembersAsync(
Guid campSeasonId, CancellationToken cancellationToken = default);

/// <summary>
/// Lists camps a human belongs to or has requested, grouped by year.
/// Used for the human's own profile dashboard.
/// </summary>
Task<IReadOnlyList<CampMembershipSummary>> GetCampMembershipsForUserAsync(
Guid userId, CancellationToken cancellationToken = default);
}

public record CampSeasonData(
Expand Down Expand Up @@ -276,3 +337,68 @@ public record CampSeasonDisplayData(string Name, string CampSlug, SoundZone? Sou
/// Lightweight camp season summary (ID, name, camp slug) for listing.
/// </summary>
public record CampSeasonBrief(Guid CampSeasonId, string Name, string CampSlug);

/// <summary>
/// Result of a camp membership request action.
/// </summary>
public record CampMemberRequestResult(
Guid CampMemberId,
CampMemberRequestOutcome Outcome,
string? Message = null);

public enum CampMemberRequestOutcome
{
/// <summary>A new pending request was created.</summary>
Created,
/// <summary>An existing pending request already existed for the human.</summary>
AlreadyPending,
/// <summary>The human is already an active member of the camp for this season.</summary>
AlreadyActive,
/// <summary>No open season for the camp — the request was not created.</summary>
NoOpenSeason
}

/// <summary>
/// Current human's camp membership state relative to a camp's open-season.
/// </summary>
public record CampMembershipState(
int? OpenSeasonYear,
Guid? OpenSeasonId,
Guid? CampMemberId,
CampMemberStatusSummary Status);

public enum CampMemberStatusSummary
{
/// <summary>No open season for the camp.</summary>
NoOpenSeason,
/// <summary>Open season exists but human has no record.</summary>
None,
/// <summary>Human has a pending request.</summary>
Pending,
/// <summary>Human is an active member.</summary>
Active
}

public record CampMemberListData(
Guid CampSeasonId,
int Year,
IReadOnlyList<CampMemberRow> Pending,
IReadOnlyList<CampMemberRow> 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);
3 changes: 3 additions & 0 deletions src/Humans.Application/NotificationSourceMapping.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
}
35 changes: 35 additions & 0 deletions src/Humans.Domain/Entities/CampMember.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using Humans.Domain.Enums;
using NodaTime;

namespace Humans.Domain.Entities;

/// <summary>
/// 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.
/// </summary>
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; }
}
6 changes: 6 additions & 0 deletions src/Humans.Domain/Enums/AuditAction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ public enum AuditAction
CampPrimaryLeadTransferred,
CampImageUploaded,
CampImageDeleted,
CampMemberRequested,
CampMemberApproved,
CampMemberRejected,
CampMemberRemoved,
CampMemberWithdrawn,
CampMemberLeft,
ShiftSignupConfirmed,
ShiftSignupRefused,
ShiftSignupVoluntold,
Expand Down
16 changes: 16 additions & 0 deletions src/Humans.Domain/Enums/CampMemberStatus.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace Humans.Domain.Enums;

/// <summary>
/// Status of a human's membership in a camp for a specific season.
/// </summary>
public enum CampMemberStatus
{
/// <summary>The human has requested membership and is awaiting lead approval.</summary>
Pending = 0,

/// <summary>The human is an active member of the camp for the season.</summary>
Active = 1,

/// <summary>Membership was removed or withdrawn. Soft-deleted row preserved for audit.</summary>
Removed = 2
}
11 changes: 10 additions & 1 deletion src/Humans.Domain/Enums/NotificationSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,5 +76,14 @@ public enum NotificationSource
FacilitatedMessageReceived = 23,

/// <summary>A new legal document version was published.</summary>
LegalDocumentPublished = 24
LegalDocumentPublished = 24,

/// <summary>A camp membership request was approved by a lead.</summary>
CampMembershipApproved = 25,

/// <summary>A camp membership request was rejected by a lead.</summary>
CampMembershipRejected = 26,

/// <summary>A human requested membership in a camp (notifies leads).</summary>
CampMembershipRequested = 27
}
Original file line number Diff line number Diff line change
@@ -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<CampMember>
{
public void Configure(EntityTypeBuilder<CampMember> builder)
{
builder.ToTable("camp_members");

builder.Property(m => m.Status).HasConversion<string>().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);
}
}
Loading
Loading