Skip to content
Merged
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
40 changes: 34 additions & 6 deletions src/Sections/Humans.Shifts/Controllers/ShiftAdminController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,8 @@ public async Task<IActionResult> 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.");
Expand Down Expand Up @@ -430,7 +431,8 @@ public async Task<IActionResult> 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
{
Expand All @@ -447,26 +449,52 @@ public async Task<IActionResult> EmailTeamRotas(string slug)

[HttpPost("Email")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> EmailTeamRotas(string slug, EmailTeamRotasViewModel model)
public async Task<IActionResult> 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;
Comment thread
peterdrier marked this conversation as resolved.
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.");
Expand Down
9 changes: 5 additions & 4 deletions src/Sections/Humans.Shifts/Docs/Shifts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}`) |
Expand All @@ -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 |
Expand Down Expand Up @@ -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.
Expand Down
26 changes: 24 additions & 2 deletions src/Sections/Humans.Shifts/Docs/features/email-a-rota.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
-->
<!-- freshness:flag-on-change
Email template shape, recipient selection rules, and authorization scope — review when ShiftAdminController authorization, signup status filtering, or the coordinator-rota email body changes.
Expand Down Expand Up @@ -34,6 +37,7 @@ Source: [nobodies-collective/Humans#732](https://github.com/nobodies-collective/
- An "Email a rota" entry point is visible on the rota admin view for users who can manage the department's shifts.
- Compose form accepts a free-text message body (1–4000 characters, required).
- Compose form shows the recipient count and the list of recipient names (`BurnerName`, alphabetical) so the coordinator can verify scope before sending.
- Compose form carries an **include-shifts** checkbox, ticked by default; clearing it drops the shift section (lead-in and list) from every email.
- On submit, each distinct active signup user receives a **separate, personalised email** — not a single CC/BCC blast.
- Each email body contains the coordinator's free-text message plus that recipient's own chronologically ordered shifts on this rota.
- Shift list uses the event's timezone (matches the rota detail page convention): `"ddd MMMM d"` for all-day shifts, `"ddd MMMM d @ HH:mm"` for time-slotted shifts.
Expand All @@ -49,10 +53,25 @@ Source: [nobodies-collective/Humans#732](https://github.com/nobodies-collective/

**Acceptance Criteria:**

- Email lists only the recipient's signups on the target rota where `SignupStatus is Pending or Confirmed`.
- Email lists only the recipient's signups on the target rota where `SignupStatus is Pending or Confirmed` — and lists nothing at all when the coordinator cleared include-shifts.
- Shifts are sorted chronologically by absolute start (event timezone).
- Email rendering uses the recipient's `PreferredLanguage` culture.

### US-732.3: Coordinator thanks everyone who worked the department

**As a** department coordinator
**I want to** message everyone who held a shift with my department this event, not just those with one still ahead
**So that** I can thank the whole crew once the event is over

**Acceptance Criteria:**

- The team-wide compose form (`/Teams/{slug}/Shifts/Email`) offers **Upcoming rotas only** (default) and **All rotas in this event**.
- Choosing "all" reveals **Build / Event / Strike** checkboxes, all ticked by default, filtering on `Rota.Period`. A `RotaPeriod.All` rota is admitted by any ticked period.
- The period checkboxes apply only under "all". Hidden under "upcoming", their values never narrow the audience — `TeamRotasAudienceFilter.Includes` short-circuits on `UpcomingOnly`.
- Changing any audience control re-posts the form with `intent=refresh`, which re-previews recipients against the new selection and leaves the half-written message and its validation alone. The refresh button is the no-JS fallback.
- A send only dispatches to an audience the coordinator has been shown. The form carries the `TeamRotasAudienceFilter.Key` its recipient list was built from; if the posted selection differs — no script, or the script failed — the send is turned back, the form re-renders against the new audience with a notice, and sending again dispatches it.
- The recipient count on the Send button always reflects the previewed selection.

## Recipient Selection

The recipient set is computed once per dispatch:
Expand All @@ -75,6 +94,7 @@ A message from the coordinator for your shift:


(shift section — omitted entirely when include-shifts is cleared)
FYI, your shifts on this rota are:
- Mon July 6 @ 19:30
- Tue July 7 @ 12:30
Expand All @@ -91,6 +111,7 @@ Thank you,
- `RotaName` — subject + body context.
- `MessageText` — coordinator's free-text body.
- `ShiftLines` — pre-formatted, chronologically sorted, recipient-scoped shift labels.
- `IncludeShifts` — false drops the whole shift section. Distinct from an empty `ShiftLines`, which still prints the "no shifts yet" note.
- `Culture` — recipient's preferred language for template rendering.

## Authorization
Expand Down Expand Up @@ -124,7 +145,7 @@ Coordinator submits (POST)
→ Re-resolve team + management permission
→ Repopulate display fields (recipient list)
→ Validate ModelState (Message required, ≤4000 chars)
→ IRotaCoordinatorMessageService.SendRotaMessageAsync(rotaId, senderUserId, message)
→ IRotaCoordinatorMessageService.SendRotaMessageAsync(rotaId, senderUserId, message, includeShifts)
→ Load rota + EventSettings
→ Load active signups → group by user
→ Load sender + recipient infos
Expand All @@ -138,6 +159,7 @@ Failure paths
→ Rota missing → "Rota not found." (validation summary)
→ Empty/whitespace message → ModelState error
→ No active signups → "This rota has no active signups to email."
→ Team-wide: nothing selected → "No rota in this team matches that selection and has active signups to email."
→ Sender not found → "Sender not found."
→ Recipient skipped (no user / no email) → logged, counted, does not abort dispatch
```
Expand Down
6 changes: 6 additions & 0 deletions src/Sections/Humans.Shifts/Models/EmailRotaViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ internal sealed class EmailRotaViewModel
public int RecipientCount { get; set; }
public IReadOnlyList<string> RecipientNames { get; set; } = [];

/// <summary>
/// Whether each recipient's own shifts on this rota are listed in their email.
/// On by default; cleared for messages where the schedule is noise (a thank-you).
/// </summary>
public bool IncludeShifts { get; set; } = true;

[Required]
[StringLength(4000, MinimumLength = 1)]
public string Message { get; set; } = string.Empty;
Expand Down
44 changes: 43 additions & 1 deletion src/Sections/Humans.Shifts/Models/EmailTeamRotasViewModel.cs
Original file line number Diff line number Diff line change
@@ -1,19 +1,61 @@
using System.ComponentModel.DataAnnotations;
using Humans.Shifts.Services;

namespace Humans.Shifts.Models;

/// <summary>
/// Compose-form model for the coordinator "email everyone across this team's
/// upcoming rotas" action. Team-level analog of <see cref="EmailRotaViewModel"/>.
/// rotas" action. Team-level analog of <see cref="EmailRotaViewModel"/>.
/// </summary>
internal sealed class EmailTeamRotasViewModel
{
/// <summary>
/// Submit value that re-renders the form against the current audience selection
/// instead of sending. Posted by the audience controls, not the send button.
/// </summary>
public const string RefreshIntent = "refresh";

public string TeamSlug { get; set; } = string.Empty;
public string TeamName { get; set; } = string.Empty;
public int RotaCount { get; set; }
public int RecipientCount { get; set; }
public IReadOnlyList<string> RecipientNames { get; set; } = [];

/// <summary>
/// Whether each recipient's own shifts are listed in their email. On by default.
/// </summary>
public bool IncludeShifts { get; set; } = true;

/// <summary>
/// True (the default) narrows the audience to rotas with a shift that has not yet
/// ended. False opens the whole active event, and the period flags below then apply.
/// </summary>
public bool UpcomingOnly { get; set; } = true;

public bool IncludeBuild { get; set; } = true;

public bool IncludeEvent { get; set; } = true;

public bool IncludeStrike { get; set; } = true;

/// <summary>The audience these choices describe.</summary>
public TeamRotasAudienceFilter Filter =>
new(UpcomingOnly, IncludeBuild, IncludeEvent, IncludeStrike);

/// <summary>
/// <see cref="TeamRotasAudienceFilter.Key"/> of the audience the recipient list on
/// this form was computed from. Posted back with the send so a selection changed
/// since that preview — the script is off, or it failed — re-previews instead of
/// mailing an audience the coordinator was never shown.
/// </summary>
public string PreviewedAudience { get; set; } = TeamRotasAudienceFilter.Default.Key;

/// <summary>
/// Set when a send was turned back because the selected audience had not been
/// previewed; the form re-renders against the new audience and asks for the send again.
/// </summary>
public bool AudienceChanged { get; set; }

[Required]
[StringLength(4000, MinimumLength = 1)]
public string Message { get; set; } = string.Empty;
Expand Down
Loading
Loading