{3}{4}{5}"
+ });
+ var rota = emails.CoordinatorRotaMessage(RotaRequest() with { RotaName = "Lights & sound" });
+ var team = emails.CoordinatorTeamRotasMessage(new CoordinatorTeamRotasMessageRequest(
+ "rcpt@x.com", "Recipient", "Sender", "coord@x.com", "Bar & kitchen", "Hello",
+ [new CoordinatorRotaShiftGroup("Gate", ["Mon"])], "en"));
+
+ rota.Subject.Should().Be("Message about Lights & sound");
+ rota.HtmlBody.Should().Contain("Lights & sound");
+ team.Subject.Should().Be("Message with Bar & kitchen");
+ team.HtmlBody.Should().Contain("Bar & kitchen");
+ }
+
[HumansFact]
public void EveryTemplateHasAPreviewSample()
{
From 37f0d5b4920b29bf57e30fbb3c08e49f58fc2e40 Mon Sep 17 00:00:00 2001
From: Peter Drier
Date: Tue, 22 Sep 2026 08:08:01 +0200
Subject: [PATCH 02/29] Deduplicate ticket cache resolver
---
src/Sections/Humans.Tickets/Docs/debt.yml | 4 ----
.../Services/Stores/CachingTicketQueryService.cs | 15 +++++----------
2 files changed, 5 insertions(+), 14 deletions(-)
diff --git a/src/Sections/Humans.Tickets/Docs/debt.yml b/src/Sections/Humans.Tickets/Docs/debt.yml
index 3e65347a65..0ad7c3a0d6 100644
--- a/src/Sections/Humans.Tickets/Docs/debt.yml
+++ b/src/Sections/Humans.Tickets/Docs/debt.yml
@@ -11,10 +11,6 @@ inbox:
id: TICKETS-2
what: "TicketController.Orders/Attendees/Codes/SalesAggregates copy service DTOs into Models/TicketViewModels.cs field-by-field (TicketDashboardDtos.cs mirrored, ~150 lines; the Attendees VIP split IsVip/TaxableAmount/VipDonation is computed in the controller). Collapse: bind the DTOs directly as TicketTransferAdmin/Detail and Onsite already do, moving the VIP split onto the AttendeeRow DTO or into the view; the views declare the row types in Func helpers, so each needs a render check. Skipped by the 2026-09-05 run (finding 6) because the cloud container has no Postgres for that check."
review: panel
- - added: 2026-09-05
- id: TICKETS-3
- what: "CachingTicketQueryService carries WithInner twice — once on the outer class and once on the nested UserHoldingsCache. One resolver would do; touch when the file is next open for a behavior change (finding S4, /section-doctor on Tickets 2026-09-05)."
- review: light
- added: 2026-09-11
id: TICKETS-4
what: "TicketTransferService.ProcessTransferAsync / ApproveAsync / RejectAsync guard on request.Status == Pending with a plain read-then-write, so two overlapping Decide POSTs on the same request both pass the check and can both enter the vendor void/reissue flow (the Decide form has no submit guard either). no-concurrency-tokens rules out a row version; the fix is an idempotent guard in the service (e.g. a Processing status written before the vendor call) or a disabled-on-submit form. Raised by Codex on peterdrier/Humans#1589; health.md records it as a gap."
diff --git a/src/Sections/Humans.Tickets/Services/Stores/CachingTicketQueryService.cs b/src/Sections/Humans.Tickets/Services/Stores/CachingTicketQueryService.cs
index 53f624bde1..1b6c631868 100644
--- a/src/Sections/Humans.Tickets/Services/Stores/CachingTicketQueryService.cs
+++ b/src/Sections/Humans.Tickets/Services/Stores/CachingTicketQueryService.cs
@@ -31,7 +31,9 @@ public CachingTicketQueryService(
_orders = new OrdersCache(
async ct => await WithInner(inner => inner.GetTicketOrdersAsync(ct)),
logger);
- _userHoldings = new UserHoldingsCache(scopeFactory, clock, UserHoldingsCacheTtl, logger);
+ _userHoldings = new UserHoldingsCache(
+ (userId, ct) => WithInner(inner => inner.GetUserTicketHoldingsAsync(userId, ct)),
+ clock, UserHoldingsCacheTtl, logger);
}
public ICacheStats OrdersCacheStats => _orders;
@@ -160,7 +162,7 @@ protected override async Task WarmAllAsync(CancellationToken ct)
}
private sealed class UserHoldingsCache(
- IServiceScopeFactory scopeFactory,
+ Func> loadHoldings,
IClock clock,
Duration ttl,
ILogger logger)
@@ -183,16 +185,9 @@ private sealed class UserHoldingsCache(
protected override async ValueTask LoadRowAsync(Guid userId, CancellationToken ct)
{
- var holdings = await WithInner(inner => inner.GetUserTicketHoldingsAsync(userId, ct));
+ var holdings = await loadHoldings(userId, ct);
return new CachedUserTicketHoldings(holdings, clock.GetCurrentInstant() + ttl);
}
-
- private async Task WithInner(Func> action)
- {
- await using var scope = scopeFactory.CreateAsyncScope();
- var inner = scope.ServiceProvider.GetRequiredKeyedService(InnerServiceKey);
- return await action(inner);
- }
}
private sealed record CachedUserTicketHoldings(UserTicketHoldings Value, Instant ExpiresAt);
From 124a89f99636544f91688483d654964226cda59d Mon Sep 17 00:00:00 2001
From: Peter Drier
Date: Tue, 22 Sep 2026 08:09:27 +0200
Subject: [PATCH 03/29] Remove unused MailerLite date converter
---
src/Sections/Humans.MailerLite/Docs/debt.yml | 6 +--
.../Services/MailerLite/MailerLiteClient.cs | 1 -
.../MailerLite/MailerLiteDateConverter.cs | 33 ---------------
.../Services/MailerLiteDateConverterTests.cs | 40 -------------------
4 files changed, 1 insertion(+), 79 deletions(-)
delete mode 100644 src/Sections/Humans.MailerLite/Services/MailerLite/MailerLiteDateConverter.cs
delete mode 100644 tests/Humans.MailerLite.Tests/Services/MailerLiteDateConverterTests.cs
diff --git a/src/Sections/Humans.MailerLite/Docs/debt.yml b/src/Sections/Humans.MailerLite/Docs/debt.yml
index 1df95fe861..6e721c78b8 100644
--- a/src/Sections/Humans.MailerLite/Docs/debt.yml
+++ b/src/Sections/Humans.MailerLite/Docs/debt.yml
@@ -2,8 +2,4 @@
# /debt-sweep pools every src/Sections/*/Docs/debt.yml into the same inbox as the central ledger.
version: 1
next_id: 2
-inbox:
- - added: 2026-08-25
- id: MLITE-1
- what: "Decide whether src/Sections/Humans.MailerLite/Services/MailerLite/MailerLiteDateConverter.cs stays. It is still registered on the client's JSON options (MailerLiteClient.cs line 354) and has its own test file, but no DTO property it could convert survives: MailerLiteGroup.CreatedAt was the last Instant? on the wire and was deleted by /section-doctor on MailerLite 2026-08-25. Either a nullable Instant? comes back onto a DTO or the converter, its registration and its tests go."
- review: light
+inbox: []
diff --git a/src/Sections/Humans.MailerLite/Services/MailerLite/MailerLiteClient.cs b/src/Sections/Humans.MailerLite/Services/MailerLite/MailerLiteClient.cs
index cdde7c5aec..0ed916e334 100644
--- a/src/Sections/Humans.MailerLite/Services/MailerLite/MailerLiteClient.cs
+++ b/src/Sections/Humans.MailerLite/Services/MailerLite/MailerLiteClient.cs
@@ -351,7 +351,6 @@ private static JsonSerializerOptions BuildJson()
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
PropertyNameCaseInsensitive = true,
};
- o.Converters.Add(new MailerLiteDateConverter());
o.Converters.Add(new MailerLiteSubscriberConverter());
return o;
}
diff --git a/src/Sections/Humans.MailerLite/Services/MailerLite/MailerLiteDateConverter.cs b/src/Sections/Humans.MailerLite/Services/MailerLite/MailerLiteDateConverter.cs
deleted file mode 100644
index 2ba6cbb796..0000000000
--- a/src/Sections/Humans.MailerLite/Services/MailerLite/MailerLiteDateConverter.cs
+++ /dev/null
@@ -1,33 +0,0 @@
-using System.Globalization;
-using System.Text.Json;
-using System.Text.Json.Serialization;
-using Humans.Base.Extensions;
-using NodaTime;
-
-namespace Humans.MailerLite.Services.MailerLite;
-
-///
-/// JSON converter for MailerLite timestamp fields. Format is
-/// "YYYY-MM-DD HH:MM:SS" (space separator, no offset). Treated as UTC
-/// per ML docs.
-///
-internal sealed class MailerLiteDateConverter : JsonConverter
-{
- private const string Format = "yyyy-MM-dd HH:mm:ss";
-
- public override Instant? Read(ref Utf8JsonReader reader, Type _, JsonSerializerOptions __)
- {
- if (reader.TokenType == JsonTokenType.Null) return null;
- var raw = reader.GetString();
- if (string.IsNullOrEmpty(raw)) return null;
- var dt = DateTime.ParseExact(raw, Format, CultureInfo.InvariantCulture,
- DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal);
- return Instant.FromDateTimeUtc(DateTime.SpecifyKind(dt, DateTimeKind.Utc));
- }
-
- public override void Write(Utf8JsonWriter writer, Instant? value, JsonSerializerOptions _)
- {
- if (value is null) writer.WriteNullValue();
- else writer.WriteStringValue(value.Value.ToDateTimeUtc().ToInvariantTimestamp());
- }
-}
diff --git a/tests/Humans.MailerLite.Tests/Services/MailerLiteDateConverterTests.cs b/tests/Humans.MailerLite.Tests/Services/MailerLiteDateConverterTests.cs
deleted file mode 100644
index e3131f7838..0000000000
--- a/tests/Humans.MailerLite.Tests/Services/MailerLiteDateConverterTests.cs
+++ /dev/null
@@ -1,40 +0,0 @@
-using System.Text.Json;
-using AwesomeAssertions;
-using Humans.MailerLite.Services.MailerLite;
-using NodaTime;
-
-namespace Humans.MailerLite.Tests.Services;
-
-public class MailerLiteDateConverterTests
-{
- private static readonly JsonSerializerOptions Opts = new()
- {
- Converters = { new MailerLiteDateConverter() }
- };
-
- private sealed record Wrap(Instant? At);
-
- [HumansFact]
- public void Reads_MailerLiteSpaceSeparatedDateAsUtc()
- {
- var json = """{"At":"2026-05-12 09:30:00"}""";
- var wrap = JsonSerializer.Deserialize(json, Opts);
- wrap!.At.Should().Be(Instant.FromUtc(2026, 5, 12, 9, 30, 0));
- }
-
- [HumansFact]
- public void Reads_NullAsNull()
- {
- var json = """{"At":null}""";
- var wrap = JsonSerializer.Deserialize(json, Opts);
- wrap!.At.Should().BeNull();
- }
-
- [HumansFact]
- public void Reads_MissingFieldAsNull()
- {
- var json = "{}";
- var wrap = JsonSerializer.Deserialize(json, Opts);
- wrap!.At.Should().BeNull();
- }
-}
From 2f7bd07691ab789ee55a5548ad0da2e749e707c1 Mon Sep 17 00:00:00 2001
From: Peter Drier
Date: Tue, 22 Sep 2026 08:12:01 +0200
Subject: [PATCH 04/29] Move camp role slot sorting out of repository
---
src/Sections/Humans.Camps/Data/CampRepository.Roles.cs | 1 -
.../Baselines/DisplaySortInControllers.baseline.txt | 2 --
2 files changed, 3 deletions(-)
diff --git a/src/Sections/Humans.Camps/Data/CampRepository.Roles.cs b/src/Sections/Humans.Camps/Data/CampRepository.Roles.cs
index 2759ac4ad2..082449ef4e 100644
--- a/src/Sections/Humans.Camps/Data/CampRepository.Roles.cs
+++ b/src/Sections/Humans.Camps/Data/CampRepository.Roles.cs
@@ -135,7 +135,6 @@ public async Task> GetAssignmentsForSeasonAsyn
.Include(a => a.Definition)
.Include(a => a.CampMember)
.Where(a => a.CampSeasonId == campSeasonId)
- .OrderBy(a => a.Definition.SortOrder).ThenBy(a => a.AssignedAt)
.ToListAsync(ct);
}
diff --git a/tests/Humans.Web.Tests/Architecture/Baselines/DisplaySortInControllers.baseline.txt b/tests/Humans.Web.Tests/Architecture/Baselines/DisplaySortInControllers.baseline.txt
index 58f65b6872..99232c8336 100644
--- a/tests/Humans.Web.Tests/Architecture/Baselines/DisplaySortInControllers.baseline.txt
+++ b/tests/Humans.Web.Tests/Architecture/Baselines/DisplaySortInControllers.baseline.txt
@@ -8,8 +8,6 @@
src/Sections/Humans.Camps/Data/CampRepository.Roles.cs:OrderBy#1
src/Sections/Humans.Camps/Data/CampRepository.Roles.cs:ThenBy#1
-src/Sections/Humans.Camps/Data/CampRepository.Roles.cs:OrderBy#2
-src/Sections/Humans.Camps/Data/CampRepository.Roles.cs:ThenBy#2
src/Sections/Humans.GoogleIntegration/Data/GoogleResourceRepository.cs:OrderBy#1
src/Sections/Humans.GoogleIntegration/Data/GoogleResourceRepository.cs:OrderBy#2
src/Sections/Humans.Teams/Data/TeamRepository.cs:OrderBy#1
From 246012750a8b2e12896fbc98fbf4f08c86fcc975 Mon Sep 17 00:00:00 2001
From: Peter Drier
Date: Tue, 22 Sep 2026 08:15:01 +0200
Subject: [PATCH 05/29] Consolidate shift signup helpers
---
src/Sections/Humans.Shifts/Docs/debt.yml | 8 -----
.../Services/ShiftSignupService.cs | 33 +++++++++++--------
2 files changed, 20 insertions(+), 21 deletions(-)
diff --git a/src/Sections/Humans.Shifts/Docs/debt.yml b/src/Sections/Humans.Shifts/Docs/debt.yml
index 22811cf393..ca2828b824 100644
--- a/src/Sections/Humans.Shifts/Docs/debt.yml
+++ b/src/Sections/Humans.Shifts/Docs/debt.yml
@@ -3,14 +3,6 @@
version: 1
next_id: 5
inbox:
- - added: 2026-09-05
- id: SHIFTS-1
- what: "ShiftSignupService re-derives 'is privileged' as `flags.HasFlag(Privileged) || await shiftMgmt.CanApproveSignupsAsync(...)` at several sites (lines 61, 88, 165, 223, 544, 603, 848, 967 as of 2026-09-20). One private helper. Found by /section-doctor on Shifts 2026-09-05 (finding 18). (split 2026-09-20 from the bundled row of the same date; one row per independently-fixable defect.)"
- review: light
- - added: 2026-09-05
- id: SHIFTS-2
- what: "ShiftSignupService formats the audit day label `es.GateOpeningDate.PlusDays(dayOffset).ToWeekdayDayMonth()` inline at several sites (lines 119, 306, 433, 757, 809 as of 2026-09-20). One private helper. Found by /section-doctor on Shifts 2026-09-05 (finding 18). (split 2026-09-20 from the bundled row of the same date; one row per independently-fixable defect.)"
- review: light
- added: 2026-09-05
id: SHIFTS-3
what: "The GateOpeningDate.PlusDays(offset) window math is written out at ShiftManagementService, ShiftFilterResolver.cs:32-42, VolunteerTrackingController.cs:140-154, ShiftDashboardPageBuilder — where one helper on the event calendar would carry it (found by /section-doctor on Shifts, 2026-09-05, finding N12)."
diff --git a/src/Sections/Humans.Shifts/Services/ShiftSignupService.cs b/src/Sections/Humans.Shifts/Services/ShiftSignupService.cs
index 10094ea7f9..998cf9d58e 100644
--- a/src/Sections/Humans.Shifts/Services/ShiftSignupService.cs
+++ b/src/Sections/Humans.Shifts/Services/ShiftSignupService.cs
@@ -67,7 +67,7 @@ public async Task SignUpAsync(
var calendar = await calendarResolver.GetAsync(shift.Rota.EventSettingsId);
if (calendar is null) return SignupResult.Fail("Event calendar not configured.");
var now = clock.GetCurrentInstant();
- isPrivileged = isPrivileged || await shiftMgmt.CanApproveSignupsAsync(userId, shift.Rota.TeamId);
+ isPrivileged = await IsPrivilegedAsync(userId, shift.Rota.TeamId, isPrivileged);
if (!localEs.IsShiftBrowsingOpen && !isPrivileged)
return SignupResult.Fail("Shift browsing is not currently open.");
@@ -125,7 +125,7 @@ public async Task SignUpAsync(
if (autoConfirm && shift.IsEarlyEntry)
earlyEntryInvalidator.InvalidateUser(userId);
- var shiftDate = calendar.GateOpeningDate.PlusDays(shift.DayOffset).ToWeekdayDayMonth();
+ var shiftDate = FormatAuditDay(calendar, shift.DayOffset);
var statusSuffix = autoConfirm ? "confirmed" : "pending";
await auditLogService.LogAsync(
AuditAction.ShiftSignupCreated, nameof(ShiftSignup), signup.Id,
@@ -172,7 +172,7 @@ public async Task ApproveAsync(Guid signupId, Guid reviewerUserId)
var now = clock.GetCurrentInstant();
if (signup.Shift.IsEarlyEntry && calendar.IsEarlyEntryClosed(now))
{
- var isPrivileged = await shiftMgmt.CanApproveSignupsAsync(reviewerUserId, signup.Shift.Rota.TeamId);
+ var isPrivileged = await IsPrivilegedAsync(reviewerUserId, signup.Shift.Rota.TeamId);
if (!isPrivileged)
return SignupResult.Fail("Cannot approve build shift signups after early entry close.");
}
@@ -231,7 +231,7 @@ public async Task BailAsync(Guid signupId, Guid actorUserId, strin
if (calendar is null) return SignupResult.Fail("Event calendar not configured.");
var now = clock.GetCurrentInstant();
var isOwner = signup.UserId == actorUserId;
- var isPrivileged = await shiftMgmt.CanApproveSignupsAsync(actorUserId, signup.Shift.Rota.TeamId);
+ var isPrivileged = await IsPrivilegedAsync(actorUserId, signup.Shift.Rota.TeamId);
// Auth: must be signup owner or privileged (dept coordinator/NoInfoAdmin/Admin)
if (!isOwner && !isPrivileged)
@@ -315,7 +315,7 @@ public async Task VoluntellAsync(Guid userId, Guid shiftId, Guid e
await auditLogService.LogAsync(
AuditAction.ShiftSignupVoluntold, nameof(ShiftSignup), signup.Id,
- $"shift '{shift.Rota.Name}' on {calendar.GateOpeningDate.PlusDays(shift.DayOffset).ToWeekdayDayMonth()}",
+ $"shift '{shift.Rota.Name}' on {FormatAuditDay(calendar, shift.DayOffset)}",
enrollerUserId,
userId, nameof(User));
@@ -443,7 +443,7 @@ public async Task VoluntellRangeAsync(Guid userId, Guid rotaId, in
{
await auditLogService.LogAsync(
AuditAction.ShiftSignupVoluntold, nameof(ShiftSignup), auditedSignup.Id,
- $"'{rota.Name}' on {calendar.GateOpeningDate.PlusDays(dayOffset).ToWeekdayDayMonth()} (range)",
+ $"'{rota.Name}' on {FormatAuditDay(calendar, dayOffset)} (range)",
enrollerUserId,
userId, nameof(User));
}
@@ -557,7 +557,7 @@ public async Task SignUpRangeAsync(
var calendar = await calendarResolver.GetAsync(rota.EventSettingsId);
if (calendar is null) return SignupResult.Fail("Event calendar not configured.");
var now = clock.GetCurrentInstant();
- isPrivileged = isPrivileged || await shiftMgmt.CanApproveSignupsAsync(userId, rota.TeamId);
+ isPrivileged = await IsPrivilegedAsync(userId, rota.TeamId, isPrivileged);
if (!localEs.IsShiftBrowsingOpen && !isPrivileged)
return SignupResult.Fail("Shift browsing is not currently open.");
@@ -771,8 +771,15 @@ private static string AppendRangeWarning(string? warning, string nextWarning)
=> warning is null ? nextWarning : $"{warning} {nextWarning}";
private static string FormatRangeDayList(EventSettingsInfo eventSettings, IEnumerable dayOffsets)
- => string.Join(", ", dayOffsets.Select(offset =>
- eventSettings.GateOpeningDate.PlusDays(offset).ToWeekdayDayMonth()));
+ => string.Join(", ", dayOffsets.Select(offset => FormatAuditDay(eventSettings, offset)));
+
+ private static string FormatAuditDay(EventSettingsInfo eventSettings, int dayOffset) =>
+ eventSettings.GateOpeningDate.PlusDays(dayOffset).ToWeekdayDayMonth();
+
+ private Task IsPrivilegedAsync(Guid userId, Guid teamId, bool alreadyPrivileged = false) =>
+ alreadyPrivileged
+ ? Task.FromResult(true)
+ : shiftMgmt.CanApproveSignupsAsync(userId, teamId);
private RangeSignupCreation StageRangeSignups(
Guid userId,
@@ -824,7 +831,7 @@ private async Task AuditRangeSignupsAsync(
await auditLogService.LogAsync(
AuditAction.ShiftSignupCreated,
nameof(ShiftSignup), auditedSignup.Id,
- $"'{rota.Name}' on {eventSettings.GateOpeningDate.PlusDays(dayOffset).ToWeekdayDayMonth()} (range, {statusSuffix})",
+ $"'{rota.Name}' on {FormatAuditDay(eventSettings, dayOffset)} (range, {statusSuffix})",
userId,
userId, nameof(User));
}
@@ -863,7 +870,7 @@ public async Task ApproveRangeAsync(Guid signupBlockId, Guid revie
if (signup.Shift.IsEarlyEntry && calendar.IsEarlyEntryClosed(now))
{
- var isPrivileged = await shiftMgmt.CanApproveSignupsAsync(reviewerUserId, signup.Shift.Rota.TeamId);
+ var isPrivileged = await IsPrivilegedAsync(reviewerUserId, signup.Shift.Rota.TeamId);
if (!isPrivileged)
return SignupResult.Fail("Cannot approve build shift signups after early entry close.");
}
@@ -983,7 +990,7 @@ public async Task BailRangeAsync(Guid signupBlockId, Guid actorUserId, string? r
?? throw new InvalidOperationException("Event calendar not configured.");
var now = clock.GetCurrentInstant();
var isOwner = firstSignup.UserId == actorUserId;
- var isPrivileged = await shiftMgmt.CanApproveSignupsAsync(actorUserId, firstSignup.Shift.Rota.TeamId);
+ var isPrivileged = await IsPrivilegedAsync(actorUserId, firstSignup.Shift.Rota.TeamId);
if (!isOwner && !isPrivileged)
throw new InvalidOperationException("Not authorized to bail this signup block.");
@@ -1186,7 +1193,7 @@ private async Task DispatchSignupChangeNotificationAsync(ShiftSignup signup, Shi
var calendar = await calendarResolver.GetAsync(rota.EventSettingsId);
if (calendar is null) return;
var shiftDate = calendar.GateOpeningDate.PlusDays(shift.DayOffset);
- var enrichedDescription = $"{changeDescription} ({rotaName}, {shiftDate.ToWeekdayDayMonth()})";
+ var enrichedDescription = $"{changeDescription} ({rotaName}, {FormatAuditDay(calendar, shift.DayOffset)})";
var team = await TeamService.GetTeamAsync(teamId);
var coordinatorIds = team?.Members
From d10c539147335546e08b9f3e008e832515fdd4f4 Mon Sep 17 00:00:00 2001
From: Peter Drier
Date: Tue, 22 Sep 2026 08:16:22 +0200
Subject: [PATCH 06/29] Remove unused shift date local
---
src/Sections/Humans.Shifts/Services/ShiftSignupService.cs | 1 -
1 file changed, 1 deletion(-)
diff --git a/src/Sections/Humans.Shifts/Services/ShiftSignupService.cs b/src/Sections/Humans.Shifts/Services/ShiftSignupService.cs
index 998cf9d58e..8b091be5ca 100644
--- a/src/Sections/Humans.Shifts/Services/ShiftSignupService.cs
+++ b/src/Sections/Humans.Shifts/Services/ShiftSignupService.cs
@@ -1192,7 +1192,6 @@ private async Task DispatchSignupChangeNotificationAsync(ShiftSignup signup, Shi
// Calendar comes from Settings, not this row's own (dead) columns (nobodies-collective/Humans#1631).
var calendar = await calendarResolver.GetAsync(rota.EventSettingsId);
if (calendar is null) return;
- var shiftDate = calendar.GateOpeningDate.PlusDays(shift.DayOffset);
var enrichedDescription = $"{changeDescription} ({rotaName}, {FormatAuditDay(calendar, shift.DayOffset)})";
var team = await TeamService.GetTeamAsync(teamId);
From c21664c3edf729fcf3727c43a1500272d0e981a7 Mon Sep 17 00:00:00 2001
From: Peter Drier
Date: Tue, 22 Sep 2026 08:22:27 +0200
Subject: [PATCH 07/29] Localize container validation errors
---
src/Sections/Humans.Containers/ContainersResource.ca.resx | 6 ++++++
src/Sections/Humans.Containers/ContainersResource.de.resx | 6 ++++++
src/Sections/Humans.Containers/ContainersResource.es.resx | 6 ++++++
src/Sections/Humans.Containers/ContainersResource.fr.resx | 6 ++++++
src/Sections/Humans.Containers/ContainersResource.it.resx | 6 ++++++
src/Sections/Humans.Containers/ContainersResource.resx | 2 ++
.../Humans.Containers/Controllers/ContainerController.cs | 3 ++-
src/Sections/Humans.Containers/Docs/debt.yml | 4 ----
src/Sections/Humans.Containers/Services/Service.cs | 7 ++++---
.../Humans.Containers.Tests/Services/ServiceImageTests.cs | 8 ++++----
10 files changed, 42 insertions(+), 12 deletions(-)
diff --git a/src/Sections/Humans.Containers/ContainersResource.ca.resx b/src/Sections/Humans.Containers/ContainersResource.ca.resx
index 69fb1cd979..baa3402ae5 100644
--- a/src/Sections/Humans.Containers/ContainersResource.ca.resx
+++ b/src/Sections/Humans.Containers/ContainersResource.ca.resx
@@ -123,6 +123,12 @@
Contenidor eliminat.
+
+ El nom del contenidor no pot contenir <, > ni $.
+
+
+ Un contenidor pot tenir com a màxim 5 imatges.
+
Anterior
diff --git a/src/Sections/Humans.Containers/ContainersResource.de.resx b/src/Sections/Humans.Containers/ContainersResource.de.resx
index 45612a82e8..8dec425165 100644
--- a/src/Sections/Humans.Containers/ContainersResource.de.resx
+++ b/src/Sections/Humans.Containers/ContainersResource.de.resx
@@ -123,6 +123,12 @@
Container gelöscht.
+
+ Der Containername darf <, > oder $ nicht enthalten.
+
+
+ Ein Container darf höchstens 5 Bilder haben.
+
Zurück
diff --git a/src/Sections/Humans.Containers/ContainersResource.es.resx b/src/Sections/Humans.Containers/ContainersResource.es.resx
index be2d7940da..8432f1f02a 100644
--- a/src/Sections/Humans.Containers/ContainersResource.es.resx
+++ b/src/Sections/Humans.Containers/ContainersResource.es.resx
@@ -123,6 +123,12 @@
Contenedor eliminado.
+
+ El nombre del contenedor no puede contener <, > ni $.
+
+
+ Un contenedor puede tener como máximo 5 imágenes.
+
Anterior
diff --git a/src/Sections/Humans.Containers/ContainersResource.fr.resx b/src/Sections/Humans.Containers/ContainersResource.fr.resx
index 709dd7de5e..34332f892c 100644
--- a/src/Sections/Humans.Containers/ContainersResource.fr.resx
+++ b/src/Sections/Humans.Containers/ContainersResource.fr.resx
@@ -123,6 +123,12 @@
Conteneur supprimé.
+
+ Le nom du conteneur ne doit pas contenir <, > ni $.
+
+
+ Un conteneur peut avoir au maximum 5 images.
+
Précédent
diff --git a/src/Sections/Humans.Containers/ContainersResource.it.resx b/src/Sections/Humans.Containers/ContainersResource.it.resx
index 2704d5dba3..9a38e6e7e3 100644
--- a/src/Sections/Humans.Containers/ContainersResource.it.resx
+++ b/src/Sections/Humans.Containers/ContainersResource.it.resx
@@ -123,6 +123,12 @@
Contenitore eliminato.
+
+ Il nome del container non può contenere <, > o $.
+
+
+ Un container può avere al massimo 5 immagini.
+
Precedente
diff --git a/src/Sections/Humans.Containers/ContainersResource.resx b/src/Sections/Humans.Containers/ContainersResource.resx
index 02e0346d08..6b1336a69c 100644
--- a/src/Sections/Humans.Containers/ContainersResource.resx
+++ b/src/Sections/Humans.Containers/ContainersResource.resx
@@ -63,6 +63,8 @@
Container added.Container updated.Container deleted.
+ Container name must not contain <, >, or $.
+ A container can have at most 5 images.PreviousNext
diff --git a/src/Sections/Humans.Containers/Controllers/ContainerController.cs b/src/Sections/Humans.Containers/Controllers/ContainerController.cs
index 3e7b806b97..afba2903b3 100644
--- a/src/Sections/Humans.Containers/Controllers/ContainerController.cs
+++ b/src/Sections/Humans.Containers/Controllers/ContainerController.cs
@@ -159,7 +159,8 @@ private async Task TryRunContainerWriteAsync(
catch (InvalidOperationException ex)
{
logger.LogWarning("Container write failed for camp {Slug}: {Message}", slug, ex.Message);
- SetError(ex.Message);
+ var localized = localizer[ex.Message];
+ SetError(localized.ResourceNotFound ? ex.Message : localized.Value);
return RedirectToAction(nameof(Index), new { slug });
}
diff --git a/src/Sections/Humans.Containers/Docs/debt.yml b/src/Sections/Humans.Containers/Docs/debt.yml
index 5715825373..8ac002be5d 100644
--- a/src/Sections/Humans.Containers/Docs/debt.yml
+++ b/src/Sections/Humans.Containers/Docs/debt.yml
@@ -8,7 +8,3 @@ inbox:
what: "No controller tests for ContainerController (finding 13, /section-doctor on Containers 2026-09-08): the 403 on /Camp/{slug}/Containers for a non-lead, the Forbid on Create/Edit/Delete, the NotFound on an unknown slug or id, and the ModelState → flash → redirect path are unpinned end to end; the handler is unit-tested by hand-built context only. Needs the controller-test scaffolding other sections use (HumansControllerBase + IAuthorizationService substitute)."
review: panel
root: CENTRAL-56
- - added: 2026-09-14
- id: CONT-2
- what: "Service validation failures reach the member untranslated: ContainerController's catch hands ex.Message to SetError, so \"A container can have at most 5 images.\" and the name-character message render in English in every culture (finding 5, /section-doctor on Containers 2026-09-08). Peter 2026-09-14: accept English for now, do the error-key pass later — Service throws a key the controller localizes against ContainersResource, rather than a formatted sentence. The two messages live in Service.ValidateName and the image-count guard."
- review: light
diff --git a/src/Sections/Humans.Containers/Services/Service.cs b/src/Sections/Humans.Containers/Services/Service.cs
index 8f5ca4f91f..f5bcaac039 100644
--- a/src/Sections/Humans.Containers/Services/Service.cs
+++ b/src/Sections/Humans.Containers/Services/Service.cs
@@ -25,6 +25,8 @@ internal sealed class Service(
new(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg", ".png", ".webp" };
private const long MaxImageBytes = 10 * 1024 * 1024;
private const int MaxImagesPerContainer = 5;
+ internal const string InvalidNameError = "Containers_Error_InvalidName";
+ internal const string TooManyImagesError = "Containers_Error_TooManyImages";
public async Task> GetByCampAsync(Guid campId, CancellationToken ct = default)
{
@@ -284,7 +286,7 @@ private static void ValidateName(string name)
{
if (name.IndexOfAny(InvalidNameChars) >= 0)
{
- throw new InvalidOperationException("Container name must not contain <, > or $.");
+ throw new InvalidOperationException(InvalidNameError);
}
}
@@ -292,8 +294,7 @@ private static void ValidateImageCount(int total)
{
if (total > MaxImagesPerContainer)
{
- throw new InvalidOperationException(
- $"A container can have at most {MaxImagesPerContainer} images.");
+ throw new InvalidOperationException(TooManyImagesError);
}
}
diff --git a/tests/Humans.Containers.Tests/Services/ServiceImageTests.cs b/tests/Humans.Containers.Tests/Services/ServiceImageTests.cs
index c118ae56db..bef8a8414a 100644
--- a/tests/Humans.Containers.Tests/Services/ServiceImageTests.cs
+++ b/tests/Humans.Containers.Tests/Services/ServiceImageTests.cs
@@ -108,7 +108,7 @@ public async Task CreateAsync_RejectsMoreThanFiveImages()
NewImages: FakeImages(6)), ct: TestContext.Current.CancellationToken);
await act.Should().ThrowAsync()
- .WithMessage("*at most 5 images*");
+ .WithMessage(Service.TooManyImagesError);
}
[HumansFact]
@@ -123,7 +123,7 @@ public async Task UpdateAsync_RejectsWhenAddedImagesWouldExceedFive()
NewImages: FakeImages(2)), actorUserId: Guid.NewGuid(), ct: TestContext.Current.CancellationToken);
await act.Should().ThrowAsync()
- .WithMessage("*at most 5 images*");
+ .WithMessage(Service.TooManyImagesError);
}
[HumansFact]
@@ -138,7 +138,7 @@ public async Task UpdateAsync_CountsTheLegacyImageAgainstTheCap()
NewImages: FakeImages(1)), actorUserId: Guid.NewGuid(), ct: TestContext.Current.CancellationToken);
await act.Should().ThrowAsync()
- .WithMessage("*at most 5 images*");
+ .WithMessage(Service.TooManyImagesError);
}
[HumansFact]
@@ -234,7 +234,7 @@ public async Task CreateAsync_RejectsNameWithTokenSignificantCharacters(string n
Description: null), ct: TestContext.Current.CancellationToken);
await act.Should().ThrowAsync()
- .WithMessage("*must not contain*");
+ .WithMessage(Service.InvalidNameError);
}
[HumansFact]
From 010e513b58e4c72fa50d2f3e0d6320227cbf56d8 Mon Sep 17 00:00:00 2001
From: Peter Drier
Date: Tue, 22 Sep 2026 08:25:26 +0200
Subject: [PATCH 08/29] Move campaign tracking sort out of repository
---
.../Humans.Campaigns/Data/CampaignRepository.cs | 3 +--
.../Humans.Campaigns/Data/ICampaignRepository.cs | 3 ++-
.../Humans.Campaigns/Services/CampaignService.cs | 1 +
.../Services/CampaignServiceTests.cs | 12 ++++++++++++
.../Baselines/DisplaySortInControllers.baseline.txt | 1 -
5 files changed, 16 insertions(+), 4 deletions(-)
diff --git a/src/Sections/Humans.Campaigns/Data/CampaignRepository.cs b/src/Sections/Humans.Campaigns/Data/CampaignRepository.cs
index 16ecaf84e8..255720e6dc 100644
--- a/src/Sections/Humans.Campaigns/Data/CampaignRepository.cs
+++ b/src/Sections/Humans.Campaigns/Data/CampaignRepository.cs
@@ -61,8 +61,7 @@ public async Task> GetCodeTracking
return await ctx.Campaigns
.AsNoTracking()
.Where(c => c.Status == CampaignStatus.Active || c.Status == CampaignStatus.Completed)
- .OrderByDescending(c => c.CreatedAt)
- .Select(c => new CampaignCodeTrackingSummaryRow(c.Id, c.Title))
+ .Select(c => new CampaignCodeTrackingSummaryRow(c.Id, c.Title, c.CreatedAt))
.ToListAsync(ct);
}
diff --git a/src/Sections/Humans.Campaigns/Data/ICampaignRepository.cs b/src/Sections/Humans.Campaigns/Data/ICampaignRepository.cs
index f966ca0bf3..de69c5a240 100644
--- a/src/Sections/Humans.Campaigns/Data/ICampaignRepository.cs
+++ b/src/Sections/Humans.Campaigns/Data/ICampaignRepository.cs
@@ -183,7 +183,8 @@ internal sealed record GrantWithSendContext(
///
internal sealed record CampaignCodeTrackingSummaryRow(
Guid CampaignId,
- string CampaignTitle);
+ string CampaignTitle,
+ Instant CreatedAt);
///
/// One grant per row, used by .
diff --git a/src/Sections/Humans.Campaigns/Services/CampaignService.cs b/src/Sections/Humans.Campaigns/Services/CampaignService.cs
index ee90ebe63f..5c9d4954dc 100644
--- a/src/Sections/Humans.Campaigns/Services/CampaignService.cs
+++ b/src/Sections/Humans.Campaigns/Services/CampaignService.cs
@@ -574,6 +574,7 @@ public async Task GetCodeTrackingAsync(CancellationTok
.ToDictionary(g => g.Key, g => g.ToList());
var summaries = summaryRows
+ .OrderByDescending(s => s.CreatedAt)
.Select(s =>
{
grantsByCampaign.TryGetValue(s.CampaignId, out var grants);
diff --git a/tests/Humans.Campaigns.Tests/Services/CampaignServiceTests.cs b/tests/Humans.Campaigns.Tests/Services/CampaignServiceTests.cs
index 755dadb0f3..d59c5eed75 100644
--- a/tests/Humans.Campaigns.Tests/Services/CampaignServiceTests.cs
+++ b/tests/Humans.Campaigns.Tests/Services/CampaignServiceTests.cs
@@ -741,6 +741,18 @@ await CampaignsDb.CampaignGrants.AddAsync(new CampaignGrant
grant.Code == "TRACK-CODE" && grant.RedeemedAt == redeemedAt && grant.LatestEmailStatus == "Sent");
}
+ [HumansFact]
+ public async Task GetCodeTrackingAsync_ordersCampaignsNewestFirst()
+ {
+ var older = await SeedActiveCampaignWithCodesAsync(["OLDER"]);
+ Clock.AdvanceHours(1);
+ var newer = await SeedActiveCampaignWithCodesAsync(["NEWER"]);
+
+ var tracking = await _service.GetCodeTrackingAsync(Xunit.TestContext.Current.CancellationToken);
+
+ tracking.Campaigns.Select(c => c.CampaignId).Should().Equal(newer.Id, older.Id);
+ }
+
[HumansFact]
public async Task EraseForUserAsync_DeletesOnlyThatUsersGrants()
{
diff --git a/tests/Humans.Web.Tests/Architecture/Baselines/DisplaySortInControllers.baseline.txt b/tests/Humans.Web.Tests/Architecture/Baselines/DisplaySortInControllers.baseline.txt
index 99232c8336..bdf5350ad1 100644
--- a/tests/Humans.Web.Tests/Architecture/Baselines/DisplaySortInControllers.baseline.txt
+++ b/tests/Humans.Web.Tests/Architecture/Baselines/DisplaySortInControllers.baseline.txt
@@ -31,4 +31,3 @@ src/Sections/Humans.Tickets/Data/TicketRepository.cs:OrderByDescending#6
src/Sections/Humans.Tickets/Data/TicketRepository.cs:OrderByDescending#7
src/Sections/Humans.Tickets/Data/TicketRepository.cs:OrderByDescending#8
src/Sections/Humans.Tickets/Data/TicketRepository.cs:OrderByDescending#9
-src/Sections/Humans.Campaigns/Data/CampaignRepository.cs:OrderByDescending#1
From a4977e5a9c70b58cf23eda80789fafebf3b45a35 Mon Sep 17 00:00:00 2001
From: Peter Drier
Date: Tue, 22 Sep 2026 08:28:32 +0200
Subject: [PATCH 09/29] Move Google resource sorting out of repository
---
.../Humans.GoogleIntegration/Data/GoogleResourceRepository.cs | 2 --
.../Humans.GoogleIntegration/Services/TeamResourceService.cs | 2 +-
.../GoogleResourceRepositoryTests.cs | 4 ++--
.../Baselines/DisplaySortInControllers.baseline.txt | 2 --
4 files changed, 3 insertions(+), 7 deletions(-)
diff --git a/src/Sections/Humans.GoogleIntegration/Data/GoogleResourceRepository.cs b/src/Sections/Humans.GoogleIntegration/Data/GoogleResourceRepository.cs
index 8e8ec86167..3b4ea45f4a 100644
--- a/src/Sections/Humans.GoogleIntegration/Data/GoogleResourceRepository.cs
+++ b/src/Sections/Humans.GoogleIntegration/Data/GoogleResourceRepository.cs
@@ -27,7 +27,6 @@ public async Task> GetActiveByTeamIdAsync(Guid tea
return await ctx.GoogleResources
.AsNoTracking()
.Where(r => r.TeamId == teamId && r.IsActive)
- .OrderBy(r => r.ProvisionedAt)
.ToListAsync(ct);
}
@@ -44,7 +43,6 @@ public async Task>> GetA
var rows = await ctx.GoogleResources
.AsNoTracking()
.Where(r => teamIds.Contains(r.TeamId) && r.IsActive)
- .OrderBy(r => r.ProvisionedAt)
.ToListAsync(ct);
var result = new Dictionary>(teamIds.Count);
diff --git a/src/Sections/Humans.GoogleIntegration/Services/TeamResourceService.cs b/src/Sections/Humans.GoogleIntegration/Services/TeamResourceService.cs
index a76d7558f4..cb86933138 100644
--- a/src/Sections/Humans.GoogleIntegration/Services/TeamResourceService.cs
+++ b/src/Sections/Humans.GoogleIntegration/Services/TeamResourceService.cs
@@ -34,7 +34,7 @@ private IRoleAssignmentService RoleAssignmentService
public async Task> GetTeamResourcesAsync(Guid teamId, CancellationToken ct = default)
{
var resources = await repository.GetActiveByTeamIdAsync(teamId, ct);
- return resources.Select(ToSnapshot).ToList();
+ return resources.OrderBy(r => r.ProvisionedAt).Select(ToSnapshot).ToList();
}
public async Task>> GetResourcesByTeamIdsAsync(
diff --git a/tests/Humans.GoogleIntegration.Tests/GoogleResourceRepositoryTests.cs b/tests/Humans.GoogleIntegration.Tests/GoogleResourceRepositoryTests.cs
index 94fb9688c3..398aef02f7 100644
--- a/tests/Humans.GoogleIntegration.Tests/GoogleResourceRepositoryTests.cs
+++ b/tests/Humans.GoogleIntegration.Tests/GoogleResourceRepositoryTests.cs
@@ -46,7 +46,7 @@ public void Dispose()
}
[HumansFact]
- public async Task GetActiveByTeamIdAsync_OrdersByProvisionedAt_ExcludesInactive()
+ public async Task GetActiveByTeamIdAsync_ExcludesInactive()
{
var teamId = Guid.NewGuid();
var older = Seed(teamId, "older", GoogleResourceType.DriveFolder, Instant.FromUtc(2026, 4, 20, 0, 0));
@@ -57,7 +57,7 @@ public async Task GetActiveByTeamIdAsync_OrdersByProvisionedAt_ExcludesInactive(
var rows = await _repository.GetActiveByTeamIdAsync(teamId, Xunit.TestContext.Current.CancellationToken);
rows.Should().HaveCount(2);
- rows.Select(r => r.Id).Should().ContainInOrder(older.Id, newer.Id);
+ rows.Select(r => r.Id).Should().BeEquivalentTo([older.Id, newer.Id]);
}
[HumansFact]
diff --git a/tests/Humans.Web.Tests/Architecture/Baselines/DisplaySortInControllers.baseline.txt b/tests/Humans.Web.Tests/Architecture/Baselines/DisplaySortInControllers.baseline.txt
index bdf5350ad1..a19f551a33 100644
--- a/tests/Humans.Web.Tests/Architecture/Baselines/DisplaySortInControllers.baseline.txt
+++ b/tests/Humans.Web.Tests/Architecture/Baselines/DisplaySortInControllers.baseline.txt
@@ -8,8 +8,6 @@
src/Sections/Humans.Camps/Data/CampRepository.Roles.cs:OrderBy#1
src/Sections/Humans.Camps/Data/CampRepository.Roles.cs:ThenBy#1
-src/Sections/Humans.GoogleIntegration/Data/GoogleResourceRepository.cs:OrderBy#1
-src/Sections/Humans.GoogleIntegration/Data/GoogleResourceRepository.cs:OrderBy#2
src/Sections/Humans.Teams/Data/TeamRepository.cs:OrderBy#1
src/Sections/Humans.Teams/Data/TeamRepository.cs:ThenBy#1
src/Sections/Humans.Teams/Data/TeamRepository.cs:ThenBy#2
From 9ecf0a4a6b229807832a1599072ec9958a88dc93 Mon Sep 17 00:00:00 2001
From: Peter Drier
Date: Tue, 22 Sep 2026 08:32:56 +0200
Subject: [PATCH 10/29] Localize team edit page
---
src/Sections/Humans.Teams/Docs/debt.yml | 4 ---
.../Humans.Teams/TeamsResource.ca.resx | 15 ++++++++++
.../Humans.Teams/TeamsResource.de.resx | 15 ++++++++++
.../Humans.Teams/TeamsResource.es.resx | 15 ++++++++++
.../Humans.Teams/TeamsResource.fr.resx | 15 ++++++++++
.../Humans.Teams/TeamsResource.it.resx | 15 ++++++++++
src/Sections/Humans.Teams/TeamsResource.resx | 15 ++++++++++
.../Humans.Teams/Views/Team/EditTeam.cshtml | 28 +++++++++----------
8 files changed, 104 insertions(+), 18 deletions(-)
diff --git a/src/Sections/Humans.Teams/Docs/debt.yml b/src/Sections/Humans.Teams/Docs/debt.yml
index 28e6892484..f5b071d735 100644
--- a/src/Sections/Humans.Teams/Docs/debt.yml
+++ b/src/Sections/Humans.Teams/Docs/debt.yml
@@ -11,10 +11,6 @@ inbox:
id: TEAMS-2
what: "Services/TeamService.cs is the section's largest class (reforge largeClass), with UpdateTeamAsync its most complex method over a long parameter list, and Data/Configurations/TeamConfiguration.cs Configure is a single long method. The target shape blesses one inner service; recorded so the score is not mistaken for unexamined debt (finding 12, /section-doctor on Teams 2026-09-05)."
review: panel
- - added: 2026-09-05
- id: TEAMS-3
- what: "Views/TeamAdmin/EditTeam.cshtml carries hardcoded English strings on a member-facing coordinator route (/Teams/{slug}/Edit is not in localization-admin-exempt's list); each needs a TeamsResource key in every supported culture (finding 12, /section-doctor on Teams 2026-09-05)."
- review: light
- added: 2026-09-05
id: TEAMS-4
what: "Most TeamsResource keys lack the Teams_ prefix (TeamDetail_*, TeamAdmin_*, MyTeams_*, AdminEditTeam_*); pre-existing baseline, report only — a rename is a six-culture sweep plus every view binding (finding 12, /section-doctor on Teams 2026-09-05)."
diff --git a/src/Sections/Humans.Teams/TeamsResource.ca.resx b/src/Sections/Humans.Teams/TeamsResource.ca.resx
index 67c2168621..811949a3a5 100644
--- a/src/Sections/Humans.Teams/TeamsResource.ca.resx
+++ b/src/Sections/Humans.Teams/TeamsResource.ca.resx
@@ -534,4 +534,19 @@
<p>El teu equip té els recursos següents:</p><ul>{0}</ul>{0} = resource list items HTML
+ Aquest és un equip gestionat pel sistema. Pots editar la descripció i el prefix del grup de Google.
+ Slug generat automàticament:
+ s'actualitza automàticament quan canvia el nom.
+ Slug personalitzat
+ p. ex., comunicacio
+ Opcional. Quan s'estableix, aquest slug s'utilitza per a l'URL de l'equip en lloc del generat automàticament.
+ Tots dos slugs resoldran a aquest equip. Només lletres minúscules, números i guionets.
+ Inclou en la planificació pressupostària
+ En activar-ho, aquest equip obté una categoria pressupostària sota el grup de Departaments en crear un nou any pressupostari.
+ Mostra a la pàgina d'equips
+ En activar-ho, aquest subequip també apareix al directori principal d'equips.
+ Equip sensible
+ En activar-ho, afegir o aprovar persones mostra un diàleg de confirmació amb el registre d'auditoria que es crearà. Aquesta opció no és visible per a usuaris que no són administradors.
+ Activa l'entrada anticipada
+ En activar-ho, els coordinadors d'aquest equip i els administradors d'entrada anticipada poden concedir entrada anticipada als projectes d'aquest equip.
diff --git a/src/Sections/Humans.Teams/TeamsResource.de.resx b/src/Sections/Humans.Teams/TeamsResource.de.resx
index 193a5dd016..c8059c3b18 100644
--- a/src/Sections/Humans.Teams/TeamsResource.de.resx
+++ b/src/Sections/Humans.Teams/TeamsResource.de.resx
@@ -534,4 +534,19 @@
<p>Dein Team hat folgende Ressourcen:</p><ul>{0}</ul>{0} = resource list items HTML
+ Dies ist ein systemverwaltetes Team. Du kannst die Beschreibung und das Präfix der Google-Gruppe bearbeiten.
+ Automatisch erzeugter Slug:
+ wird bei einer Namensänderung automatisch aktualisiert.
+ Benutzerdefinierter Slug
+ z. B. kommunikation
+ Optional. Wenn gesetzt, wird dieser Slug statt des automatisch erzeugten für die Team-URL verwendet.
+ Beide Slugs führen zu diesem Team. Nur Kleinbuchstaben, Zahlen und Bindestriche.
+ In Budgetplanung einbeziehen
+ Wenn aktiviert, erhält dieses Team beim Erstellen eines neuen Haushaltsjahres eine Budgetkategorie unter der Gruppe Abteilungen.
+ Auf der Teamseite anzeigen
+ Wenn aktiviert, erscheint dieses Unterteam auch im Hauptverzeichnis der Teams.
+ Sensibles Team
+ Wenn aktiviert, zeigt das Hinzufügen oder Genehmigen von Personen einen Bestätigungsdialog mit dem zu erstellenden Prüfprotokoll. Diese Einstellung ist für Nicht-Administratoren nicht sichtbar.
+ Frühen Einlass aktivieren
+ Wenn aktiviert, können die Koordinatoren dieses Teams und die Administratoren für frühen Einlass den Projekten dieses Teams frühen Einlass gewähren.
diff --git a/src/Sections/Humans.Teams/TeamsResource.es.resx b/src/Sections/Humans.Teams/TeamsResource.es.resx
index 48f4de8db9..a639cde4bb 100644
--- a/src/Sections/Humans.Teams/TeamsResource.es.resx
+++ b/src/Sections/Humans.Teams/TeamsResource.es.resx
@@ -534,4 +534,19 @@
<p>Tu equipo tiene los siguientes recursos:</p><ul>{0}</ul>{0} = resource list items HTML
+ Este es un equipo gestionado por el sistema. Puedes editar la descripción y el prefijo del grupo de Google.
+ Slug generado automáticamente:
+ se actualiza automáticamente cuando cambia el nombre.
+ Slug personalizado
+ p. ej., comunicacion
+ Opcional. Cuando se establece, este slug se usa para la URL del equipo en lugar del generado automáticamente.
+ Ambos slugs resolverán a este equipo. Solo letras minúsculas, números y guiones.
+ Incluir en la planificación presupuestaria
+ Al activarlo, este equipo obtiene una categoría presupuestaria bajo el grupo de Departamentos al crear un nuevo año presupuestario.
+ Mostrar en la página de equipos
+ Al activarlo, este subequipo también aparece en el directorio principal de equipos.
+ Equipo sensible
+ Al activarlo, añadir o aprobar personas muestra un diálogo de confirmación con el registro de auditoría que se creará. Esta opción no es visible para usuarios que no sean administradores.
+ Activar acceso anticipado
+ Al activarlo, los coordinadores de este equipo y los administradores de acceso anticipado pueden conceder acceso anticipado a los proyectos de este equipo.
diff --git a/src/Sections/Humans.Teams/TeamsResource.fr.resx b/src/Sections/Humans.Teams/TeamsResource.fr.resx
index 5a719cfff6..872fae20f1 100644
--- a/src/Sections/Humans.Teams/TeamsResource.fr.resx
+++ b/src/Sections/Humans.Teams/TeamsResource.fr.resx
@@ -534,4 +534,19 @@
<p>Votre équipe dispose des ressources suivantes :</p><ul>{0}</ul>{0} = resource list items HTML
+ Cette équipe est gérée par le système. Vous pouvez modifier la description et le préfixe du groupe Google.
+ Slug généré automatiquement :
+ se met à jour automatiquement lorsque le nom change.
+ Slug personnalisé
+ p. ex. communication
+ Facultatif. Lorsqu'il est défini, ce slug est utilisé pour l'URL de l'équipe à la place de celui généré automatiquement.
+ Les deux slugs renverront vers cette équipe. Lettres minuscules, chiffres et traits d'union uniquement.
+ Inclure dans la planification budgétaire
+ Lorsqu'elle est activée, cette équipe obtient une catégorie budgétaire dans le groupe Départements lors de la création d'une nouvelle année budgétaire.
+ Afficher sur la page des équipes
+ Lorsqu'elle est activée, cette sous-équipe apparaît aussi dans l'annuaire principal des équipes.
+ Équipe sensible
+ Lorsqu'elle est activée, l'ajout ou l'approbation de personnes affiche une confirmation avec l'enregistrement d'audit qui sera créé. Cette option n'est pas visible pour les non-administrateurs.
+ Activer l'entrée anticipée
+ Lorsqu'elle est activée, les coordinateurs de cette équipe et les administrateurs d'entrée anticipée peuvent accorder une entrée anticipée aux projets de cette équipe.
diff --git a/src/Sections/Humans.Teams/TeamsResource.it.resx b/src/Sections/Humans.Teams/TeamsResource.it.resx
index 961bb88947..1d0a5f30e7 100644
--- a/src/Sections/Humans.Teams/TeamsResource.it.resx
+++ b/src/Sections/Humans.Teams/TeamsResource.it.resx
@@ -534,4 +534,19 @@
<p>Il tuo team ha le seguenti risorse:</p><ul>{0}</ul>{0} = resource list items HTML
+ Questo è un team gestito dal sistema. Puoi modificare la descrizione e il prefisso del gruppo Google.
+ Slug generato automaticamente:
+ si aggiorna automaticamente quando cambia il nome.
+ Slug personalizzato
+ ad es. comunicazione
+ Facoltativo. Quando impostato, questo slug viene usato per l'URL del team al posto di quello generato automaticamente.
+ Entrambi gli slug risolveranno a questo team. Solo lettere minuscole, numeri e trattini.
+ Includi nella pianificazione del budget
+ Quando attivato, questo team ottiene una categoria di budget nel gruppo Dipartimenti alla creazione di un nuovo anno di bilancio.
+ Mostra nella pagina dei team
+ Quando attivato, questo sotto-team appare anche nella directory principale dei team.
+ Team sensibile
+ Quando attivato, l'aggiunta o l'approvazione di persone mostra una conferma con il registro di controllo che verrà creato. Questa opzione non è visibile agli utenti non amministratori.
+ Abilita l'ingresso anticipato
+ Quando attivato, i coordinatori di questo team e gli amministratori dell'ingresso anticipato possono concedere l'ingresso anticipato ai progetti di questo team.
diff --git a/src/Sections/Humans.Teams/TeamsResource.resx b/src/Sections/Humans.Teams/TeamsResource.resx
index ed3273c579..2b527d9f5c 100644
--- a/src/Sections/Humans.Teams/TeamsResource.resx
+++ b/src/Sections/Humans.Teams/TeamsResource.resx
@@ -212,4 +212,19 @@
<p><a href="{3}">View Team Page</a></p>
<p>The Humans Team</p>{0} = user name, {1} = team name, {2} = resources HTML (or empty), {3} = team URL<p>Your team has the following resources:</p><ul>{0}</ul>{0} = resource list items HTML
+ This is a system-managed team. You can edit the description and Google Group prefix.
+ Auto-generated slug:
+ updates automatically when name changes.
+ Custom Slug
+ e.g. comms
+ Optional. When set, this slug is used for the team's URL instead of the auto-generated one.
+ Both slugs will resolve to this team. Lowercase letters, numbers, and hyphens only.
+ Include in budget planning
+ When enabled, this team gets a budget category under the Departments group when a new budget year is created.
+ Show on Teams page
+ When enabled, this sub-team also appears on the main Teams directory page.
+ Sensitive team
+ When enabled, adding or approving humans triggers a confirmation modal showing the audit record that will be created. This flag is not visible to non-admin users.
+ Enable Early Entry
+ When enabled, coordinators of this team (and Early-Entry Team Admins) can grant early entry for this team's projects.
diff --git a/src/Sections/Humans.Teams/Views/Team/EditTeam.cshtml b/src/Sections/Humans.Teams/Views/Team/EditTeam.cshtml
index a6b152bae2..0c082df84e 100644
--- a/src/Sections/Humans.Teams/Views/Team/EditTeam.cshtml
+++ b/src/Sections/Humans.Teams/Views/Team/EditTeam.cshtml
@@ -22,7 +22,7 @@
{
- This is a system-managed team. You can edit the description and Google Group prefix.
+ @Localizer["AdminEditTeam_SystemManagedHelp"]
}
@@ -37,18 +37,18 @@
-
Auto-generated slug: @Model.Slug — updates automatically when name changes.
- Optional. When set, this slug is used for the team's URL instead of the auto-generated one.
- Both slugs will resolve to this team. Lowercase letters, numbers, and hyphens only.
+ @Localizer["AdminEditTeam_CustomSlugHelp"]
+ @Localizer["AdminEditTeam_CustomSlugFormatHelp"]
}
@@ -64,8 +64,8 @@
-
-
When enabled, this team gets a budget category under the Departments group when a new budget year is created.
+
+
@Localizer["AdminEditTeam_IncludeInBudgetHelp"]
}
@@ -73,21 +73,21 @@
{
-
-
When enabled, this sub-team also appears on the main Teams directory page.
+
+
@Localizer["AdminEditTeam_ShowInDirectoryHelp"]
}
-
-
When enabled, adding or approving humans triggers a confirmation modal showing the audit record that will be created. This flag is not visible to non-admin users.
+
+
@Localizer["AdminEditTeam_SensitiveHelp"]
-
-
When enabled, coordinators of this team (and Early-Entry Team Admins) can grant early entry for this team's projects.
+
+
@Localizer["AdminEditTeam_EnableEarlyEntryHelp"]
From 25e57db1cf3a9586753be3a86728541d429ff66e Mon Sep 17 00:00:00 2001
From: Peter Drier
Date: Tue, 22 Sep 2026 08:37:28 +0200
Subject: [PATCH 11/29] Localize individual event form
---
.../Humans.Events/EventsResource.ca.resx | 19 +++++++++
.../Humans.Events/EventsResource.de.resx | 19 +++++++++
.../Humans.Events/EventsResource.es.resx | 19 +++++++++
.../Humans.Events/EventsResource.fr.resx | 19 +++++++++
.../Humans.Events/EventsResource.it.resx | 19 +++++++++
.../Humans.Events/EventsResource.resx | 19 +++++++++
.../Views/Events/IndividualEventForm.cshtml | 40 +++++++++----------
7 files changed, 134 insertions(+), 20 deletions(-)
diff --git a/src/Sections/Humans.Events/EventsResource.ca.resx b/src/Sections/Humans.Events/EventsResource.ca.resx
index 61d3bc2b89..958d1b93ef 100644
--- a/src/Sections/Humans.Events/EventsResource.ca.resx
+++ b/src/Sections/Humans.Events/EventsResource.ca.resx
@@ -93,4 +93,23 @@
La pujada ha fallat.Corregeix els errors de sota i torna-ho a provar. No s'ha desat cap esdeveniment.Vols retirar aquest esdeveniment?
+ Edita l'esdeveniment
+ Envia un esdeveniment
+ Edita
+ Nou
+ Aquest esdeveniment s'ha retornat per editar-lo. Actualitza'n els detalls i torna'l a enviar.
+ caràcters restants
+ S'admet el format Markdown
+ — Selecciona una categoria —
+ — Selecciona un lloc —
+ Esdeveniment de tot el dia
+ — Selecciona un dia —
+ Aquest esdeveniment es repeteix diversos dies
+ Dies de repetició
+ p. ex., prop de la foguera
+ p. ex., El Col·lectiu del Te
+ Es mostra a la guia en lloc del teu nom quan s'omple.
+ Torna a enviar
+ Envia per revisar
+ Cancel·la
diff --git a/src/Sections/Humans.Events/EventsResource.de.resx b/src/Sections/Humans.Events/EventsResource.de.resx
index d8d348023c..76bf9134e3 100644
--- a/src/Sections/Humans.Events/EventsResource.de.resx
+++ b/src/Sections/Humans.Events/EventsResource.de.resx
@@ -93,4 +93,23 @@
Upload fehlgeschlagen.Behebe die folgenden Fehler und versuche es erneut. Es wurden keine Veranstaltungen gespeichert.Diese Veranstaltung zurückziehen?
+ Veranstaltung bearbeiten
+ Veranstaltung einreichen
+ Bearbeiten
+ Neu
+ Diese Veranstaltung wurde zur Bearbeitung zurückgegeben. Aktualisiere die Angaben und reiche sie erneut ein.
+ Zeichen verbleiben
+ Markdown-Formatierung wird unterstützt
+ — Kategorie auswählen —
+ — Ort auswählen —
+ Ganztägige Veranstaltung
+ — Tag auswählen —
+ Diese Veranstaltung wiederholt sich an mehreren Tagen
+ Wiederholungstage
+ z. B. nahe der Feuerstelle
+ z. B. das Teekollektiv
+ Wird ausgefüllt im Guide statt deines Namens angezeigt.
+ Erneut einreichen
+ Zur Prüfung einreichen
+ Abbrechen
diff --git a/src/Sections/Humans.Events/EventsResource.es.resx b/src/Sections/Humans.Events/EventsResource.es.resx
index 62de58d97a..25431e37d1 100644
--- a/src/Sections/Humans.Events/EventsResource.es.resx
+++ b/src/Sections/Humans.Events/EventsResource.es.resx
@@ -93,4 +93,23 @@
La subida falló.Corrige los errores de abajo e inténtalo de nuevo. No se guardó ningún evento.¿Retirar este evento?
+ Editar evento
+ Enviar un evento
+ Editar
+ Nuevo
+ Este evento se devolvió para editarlo. Actualiza los detalles y vuelve a enviarlo.
+ caracteres restantes
+ Se admite formato Markdown
+ — Selecciona una categoría —
+ — Selecciona un lugar —
+ Evento de todo el día
+ — Selecciona un día —
+ Este evento se repite varios días
+ Días de repetición
+ p. ej., cerca de la hoguera
+ p. ej., El Colectivo del Té
+ Se muestra en la guía en lugar de tu nombre cuando se completa.
+ Volver a enviar
+ Enviar para revisión
+ Cancelar
diff --git a/src/Sections/Humans.Events/EventsResource.fr.resx b/src/Sections/Humans.Events/EventsResource.fr.resx
index 769c8ab52a..1e4f16a30e 100644
--- a/src/Sections/Humans.Events/EventsResource.fr.resx
+++ b/src/Sections/Humans.Events/EventsResource.fr.resx
@@ -93,4 +93,23 @@
Échec du téléversement.Corrigez les erreurs ci-dessous et réessayez. Aucun événement n'a été enregistré.Retirer cet événement ?
+ Modifier l'événement
+ Proposer un événement
+ Modifier
+ Nouveau
+ Cet événement a été renvoyé pour modification. Mettez les détails à jour et soumettez-le à nouveau.
+ caractères restants
+ Le formatage Markdown est pris en charge
+ — Sélectionner une catégorie —
+ — Sélectionner un lieu —
+ Événement sur toute la journée
+ — Sélectionner un jour —
+ Cet événement se répète sur plusieurs jours
+ Jours de répétition
+ p. ex. près du feu de camp
+ p. ex. le Collectif du Thé
+ S'affiche dans le guide à la place de votre nom lorsqu'il est renseigné.
+ Soumettre à nouveau
+ Soumettre pour examen
+ Annuler
diff --git a/src/Sections/Humans.Events/EventsResource.it.resx b/src/Sections/Humans.Events/EventsResource.it.resx
index 5d510979d5..9f4ecca8c3 100644
--- a/src/Sections/Humans.Events/EventsResource.it.resx
+++ b/src/Sections/Humans.Events/EventsResource.it.resx
@@ -93,4 +93,23 @@
Caricamento fallito.Correggi gli errori qui sotto e riprova. Nessun evento è stato salvato.Ritirare questo evento?
+ Modifica evento
+ Invia un evento
+ Modifica
+ Nuovo
+ Questo evento è stato restituito per le modifiche. Aggiorna i dettagli e invialo di nuovo.
+ caratteri rimanenti
+ La formattazione Markdown è supportata
+ — Seleziona una categoria —
+ — Seleziona un luogo —
+ Evento per tutto il giorno
+ — Seleziona un giorno —
+ Questo evento si ripete in più giorni
+ Giorni di ripetizione
+ ad es. vicino al falò
+ ad es. il Collettivo del Tè
+ Viene mostrato nella guida al posto del tuo nome quando compilato.
+ Invia di nuovo
+ Invia per revisione
+ Annulla
diff --git a/src/Sections/Humans.Events/EventsResource.resx b/src/Sections/Humans.Events/EventsResource.resx
index aa4f4c08b4..6c896c8e16 100644
--- a/src/Sections/Humans.Events/EventsResource.resx
+++ b/src/Sections/Humans.Events/EventsResource.resx
@@ -88,4 +88,23 @@
Upload failed.Fix the errors below and try again. No events were saved.Withdraw this event?
+ Edit Event
+ Submit an Event
+ Edit
+ New
+ This event was returned for edits. Update the details and resubmit.
+ characters remaining
+ Markdown formatting is supported
+ — Select category —
+ — Select venue —
+ All day event
+ — Select day —
+ This event repeats on multiple days
+ Recurrence days
+ e.g. near the fire pit
+ e.g. The Tea Collective
+ Shown in the guide instead of your name when filled in.
+ Resubmit
+ Submit for Review
+ Cancel
diff --git a/src/Sections/Humans.Events/Views/Events/IndividualEventForm.cshtml b/src/Sections/Humans.Events/Views/Events/IndividualEventForm.cshtml
index 5be4e99c7d..ada82a5ee5 100644
--- a/src/Sections/Humans.Events/Views/Events/IndividualEventForm.cshtml
+++ b/src/Sections/Humans.Events/Views/Events/IndividualEventForm.cshtml
@@ -1,29 +1,29 @@
@model Humans.Events.Models.IndividualEventFormViewModel
@{
var isEdit = Model.Id.HasValue;
- ViewData["Title"] = isEdit ? "Edit Event" : "Submit an Event";
+ ViewData["Title"] = isEdit ? Localizer["EventSubmission_EditTitle"].Value : Localizer["EventSubmission_SubmitTitle"].Value;
}
-