diff --git a/src/Sections/Humans.Shifts/Controllers/ShiftAdminController.cs b/src/Sections/Humans.Shifts/Controllers/ShiftAdminController.cs index 4abc4ba9db..bc3b134917 100644 --- a/src/Sections/Humans.Shifts/Controllers/ShiftAdminController.cs +++ b/src/Sections/Humans.Shifts/Controllers/ShiftAdminController.cs @@ -391,7 +391,8 @@ public async Task EmailRota(string slug, Guid rotaId, EmailRotaVi if (!ModelState.IsValid) return View(model); - var result = await rotaMessenger.SendRotaMessageAsync(rota.Id, user.Id, model.Message); + var result = await rotaMessenger.SendRotaMessageAsync( + rota.Id, user.Id, model.Message, model.IncludeShifts); if (!result.Succeeded) { ModelState.AddModelError(string.Empty, result.Error ?? "Failed to queue rota emails."); @@ -430,7 +431,8 @@ public async Task EmailTeamRotas(string slug) var (teamError, _, team) = await ResolveDepartmentManagementAsync(slug); if (teamError is not null) return teamError; - var preview = await rotaMessenger.GetTeamRotasRecipientPreviewAsync(team.Id); + var preview = await rotaMessenger.GetTeamRotasRecipientPreviewAsync( + team.Id, TeamRotasAudienceFilter.Default); var vm = new EmailTeamRotasViewModel { @@ -447,26 +449,52 @@ public async Task EmailTeamRotas(string slug) [HttpPost("Email")] [ValidateAntiForgeryToken] - public async Task EmailTeamRotas(string slug, EmailTeamRotasViewModel model) + public async Task EmailTeamRotas(string slug, EmailTeamRotasViewModel model, string? intent = null) { var (teamError, user, team) = await ResolveDepartmentManagementAsync(slug); if (teamError is not null) return teamError; // Repopulate display fields before any return-with-error path so the - // re-rendered form still shows the recipient list and counts. - var preview = await rotaMessenger.GetTeamRotasRecipientPreviewAsync(team.Id); + // re-rendered form still shows the recipient list and counts — recomputed + // against the audience the coordinator has currently selected. + var preview = await rotaMessenger.GetTeamRotasRecipientPreviewAsync(team.Id, model.Filter); + var previewWasShown = string.Equals( + model.PreviewedAudience, model.Filter.Key, StringComparison.Ordinal); model.TeamSlug = slug; model.TeamName = team.Name; + // The recipient list above is the one about to be rendered, so the model's key + // is the authoritative one. Drop the posted entry first: the hidden field's tag + // helper prefers ModelState's attempted value, so leaving it would re-render the + // stale key and turn every later send back through the guard below forever. + ModelState.Remove(nameof(EmailTeamRotasViewModel.PreviewedAudience)); + model.PreviewedAudience = model.Filter.Key; model.RotaCount = preview.RotaCount; model.RecipientCount = preview.RecipientNames.Count; model.RecipientNames = preview.RecipientNames .OrderBy(n => n, StringComparer.OrdinalIgnoreCase) .ToList(); + // The audience controls re-render the form so the recipient preview tracks + // the selection; the half-written message is not a validation failure yet. + if (string.Equals(intent, EmailTeamRotasViewModel.RefreshIntent, StringComparison.Ordinal)) + { + ModelState.Clear(); + return View(model); + } + + // Without the script the audience can move without a re-preview, so a send + // would mail a list the coordinator never saw. Show them this one instead. + if (!previewWasShown) + { + model.AudienceChanged = true; + return View(model); + } + if (!ModelState.IsValid) return View(model); - var result = await rotaMessenger.SendTeamRotasMessageAsync(team.Id, user.Id, model.Message); + var result = await rotaMessenger.SendTeamRotasMessageAsync( + team.Id, user.Id, model.Message, model.IncludeShifts, model.Filter); if (!result.Succeeded) { ModelState.AddModelError(string.Empty, result.Error ?? "Failed to queue team rota emails."); diff --git a/src/Sections/Humans.Shifts/Docs/Shifts.md b/src/Sections/Humans.Shifts/Docs/Shifts.md index 7317208db2..95873fe956 100644 --- a/src/Sections/Humans.Shifts/Docs/Shifts.md +++ b/src/Sections/Humans.Shifts/Docs/Shifts.md @@ -187,7 +187,7 @@ Selected routes: | `POST /Teams/{slug}/Shifts/Rotas/{rotaId}/ToggleVisibility` | Toggle `IsVisibleToVolunteers` | | `POST /Teams/{slug}/Shifts/Rotas/{rotaId}/Move` | Move rota to different team | | `POST /Teams/{slug}/Shifts/Rotas/{rotaId}/Delete` | Delete rota (404 unless the rota belongs to `{slug}`) | -| `GET/POST /Teams/{slug}/Shifts/Rotas/{rotaId}/Email` | Compose / send a coordinator message to everyone booked on one rota | +| `GET/POST /Teams/{slug}/Shifts/Rotas/{rotaId}/Email` | Compose / send a coordinator message to everyone booked on one rota (optionally with each recipient's own shift list) | | `POST /Teams/{slug}/Shifts/Shifts` | Create shift | | `POST /Teams/{slug}/Shifts/Shifts/{shiftId}` | Edit shift | | `POST /Teams/{slug}/Shifts/Shifts/{shiftId}/Delete` | Delete shift (404 unless the shift's rota belongs to `{slug}`) | @@ -203,8 +203,8 @@ Selected routes: | `POST /Teams/{slug}/Shifts/VoluntellRange` | Voluntell range | | `GET /Teams/{slug}/Shifts/Tags/Search` | Tag autocomplete | | `POST /Teams/{slug}/Shifts/Tags/Create` | Create new tag | -| `GET /Teams/{slug}/Shifts/Email` | Compose a team-wide coordinator message to everyone with an active signup across the team's upcoming rotas | -| `POST /Teams/{slug}/Shifts/Email` | Send the team-wide coordinator message | +| `GET /Teams/{slug}/Shifts/Email` | Compose a team-wide coordinator message to everyone with an active signup across the team's selected rotas | +| `POST /Teams/{slug}/Shifts/Email` | Send the team-wide coordinator message, or (`intent=refresh`, or any send whose audience has not been previewed) re-preview the recipients for the posted audience selection | | `GET /Shifts/Dashboard` | Cross-department coordinator dashboard | | `GET /Shifts/Dashboard/PostEventStats` | Post-event stats: completion/no-show rates by department (`ShiftDashboardAccess`) | | `GET /Shifts/Dashboard/SearchVolunteers` | Dashboard volunteer search | @@ -240,7 +240,8 @@ The cross-source Early Entry roster (`/Shifts/Admin/EarlyEntry`) is `EarlyEntryR - Rota visibility is controlled by `IsVisibleToVolunteers` (default: visible). Hidden rotas are only shown to privileged roles (Admin/NoInfoAdmin/VolunteerCoordinator/dept coordinator). Browse and Mine queries pass `includeHidden = isPrivileged`. The Hidden pill rendered on hidden rotas is therefore admin-only by virtue of the server-side filter (no separate role check). - Signup-list visibility on `/Shifts` is currently public to all authenticated viewers (temporary policy — see [feature 26](features/shift-signup-visibility.md)). The browse partials (`_EventRotaTable`, `_BuildStrikeRotaTable`) render avatar chips for everyone; pending signups appear faded with a dashed border and the localized "Pending" label in the hover popover. `includeSignups` is unconditionally true so the column has data; the `isPrivileged` computation is preserved so reverting visibility is a one-line flip in `ShiftsController`. Admin-side signup lists (`/Teams/{slug}/Shifts`) remain coordinator-gated via `IShiftManagementService.CanApproveSignupsAsync`. - Voluntelling (admin/coordinator-initiated signup) creates a Confirmed `ShiftSignup` with `Enrolled = true` and records `EnrolledByUserId` / `ReviewedByUserId`. Range voluntell uses a shared `SignupBlockId` and skips shifts that are full or already booked. -- The team-wide coordinator message (`/Teams/{slug}/Shifts/Email`) targets distinct users holding a **Pending or Confirmed** signup on any shift in any of the team's rotas that still has at least one shift not yet ended (`shift.GetAbsoluteEnd(eventSettings) > now` — end, not start). Each recipient gets exactly one email listing only their own shifts, grouped by rota; `Reply-To` is the sending coordinator while `From` stays the shared address. +- The team-wide coordinator message (`/Teams/{slug}/Shifts/Email`) targets distinct users holding a **Pending or Confirmed** signup on any shift in any of the team's rotas the compose form's audience selection admits. Default ("upcoming rotas only") keeps a rota with at least one shift not yet ended (`shift.GetAbsoluteEnd(eventSettings) > now` — end, not start); clearing it opens the whole active event and the Build/Event/Strike boxes then filter on `Rota.Period`, a `RotaPeriod.All` rota riding on any selected period. Those period boxes are hidden under "upcoming" and `TeamRotasAudienceFilter.Includes` ignores them there, so a hidden cleared box can never narrow an upcoming send. Each recipient gets exactly one email; `Reply-To` is the sending coordinator while `From` stays the shared address. +- Both coordinator messages carry an **include-shifts** checkbox, on by default. Ticked, the body lists the recipient's own shifts (grouped by rota for the team-wide message). Cleared — the post-event thank-you case — the shift section is dropped lead-in and all, so no "your shifts are:" line is stranded over an empty list. - Voluntell (single and range) is permitted on **past shifts** so coordinators can correct the rota retroactively; capacity ceiling and overlap checks still apply. Self-signup remains unavailable for past shifts (a browsing-window property, not a hard service guard). In the department admin view, past and future shifts are managed through the **same Manage control**: a unified panel listing confirmed humans with **Remove** (always), plus **Mark No-Show** and **Bail Range** only when the shift is past (post-shift corrections). Past shifts additionally list no-show/bailed humans as read-only history. The **Voluntell** control is available on all shifts. - Range signups (build/strike rotas) create signups for every all-day shift in the date range under one `SignupBlockId`; conflicts and capacity are reported as warnings, not failures (provided at least one slot is available). The whole block is bailed/approved/refused atomically by `BailRangeAsync` / `ApproveRangeAsync` / `RefuseRangeAsync`. - "Active event" has one home: Settings' `SaveEventSettingsAsync` enforces at-most-one-Active row. Shifts' knobs row carries no `IsActive` invariant of its own — `SaveKnobsAsync`/`CreateRotaAsync` no longer check or set it (nobodies-collective/Humans#1631). A knobs row is created on demand: the first rota or knob edit against an event id Settings knows about creates it. diff --git a/src/Sections/Humans.Shifts/Docs/features/email-a-rota.md b/src/Sections/Humans.Shifts/Docs/features/email-a-rota.md index 8591a2fbc0..b0bf58612e 100644 --- a/src/Sections/Humans.Shifts/Docs/features/email-a-rota.md +++ b/src/Sections/Humans.Shifts/Docs/features/email-a-rota.md @@ -5,7 +5,10 @@ src/Sections/Humans.Email.Contracts/** src/Sections/Humans.Shifts/Controllers/ShiftAdminController.cs src/Sections/Humans.Shifts/Models/EmailRotaViewModel.cs + src/Sections/Humans.Shifts/Models/EmailTeamRotasViewModel.cs + src/Sections/Humans.Shifts/Services/TeamRotasAudienceFilter.cs src/Sections/Humans.Shifts/Views/ShiftAdmin/EmailRota.cshtml + src/Sections/Humans.Shifts/Views/ShiftAdmin/EmailTeamRotas.cshtml -->