Skip to content

RE1-T115 Changing TTS voice provider.#362

Merged
ucswift merged 2 commits intomasterfrom
develop
May 4, 2026
Merged

RE1-T115 Changing TTS voice provider.#362
ucswift merged 2 commits intomasterfrom
develop

Conversation

@ucswift
Copy link
Copy Markdown
Member

@ucswift ucswift commented May 4, 2026

Summary by CodeRabbit

  • New Features

    • Text-to-speech now uses the Piper TTS engine with prebuilt voice models and updated audio processing.
  • Bug Fixes

    • Safer null-handling in subscription/limits paths to prevent edge-case errors.
  • Behavioral Changes

    • Weather alerts now send messages per recipient rather than a single aggregated message.
  • Documentation

    • Replaced project guidance with an expanded project guide and updated deployment docs (Docker/Kubernetes).
  • Tests

    • Updated and expanded tests to reflect Piper integration and improved test fixtures.

@request-info
Copy link
Copy Markdown

request-info Bot commented May 4, 2026

Thanks for opening this, but we'd appreciate a little more information. Could you update it with more details?

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 4, 2026

📝 Walkthrough

Walkthrough

This PR migrates the TTS subsystem from eSpeak NG to Piper (config, runtime, Docker image, model resolution, invocation, health checks), updates tests and documentation to match Piper, switches WeatherAlertService to use ICommunicationService with per-recipient sends, tightens some null-safety checks in billing/plan handling, and replaces CLAUDE.md with a Resgrid project guide.

Changes

Text-to-Speech Engine Migration: eSpeak → Piper

Layer / File(s) Summary
Configuration Shape
Core/Resgrid.Config/TtsConfig.cs, Web/Resgrid.Web.Tts/Configuration/TtsOptions.cs
Removed EspeakExecutable; added PiperExecutable ("piper") and PiperModelDirectory ("/usr/local/share/piper-voices") with required defaults.
Core Synthesis Logic
Web/Resgrid.Web.Tts/Services/AudioProcessingService.cs
Replaced eSpeak pipeline with Piper: added model map (VoiceModelMap), GetPiperInvocation, ResolveModelName, ComputeLengthScale, RunPiperAsync/CreatePiperStartInfo; removed eSpeak/MBROLA helpers; GetEffectiveSynthesisProfile now returns Piper model name and original speed.
Configuration Wiring & Health
Web/Resgrid.Web.Tts/Configuration/ServiceCollectionExtensions.cs, Web/Resgrid.Web.Tts/Health/TtsDependencyHealthCheck.cs
Wire PiperExecutable and PiperModelDirectory from TtsConfig when present; health check validates Piper settings (removed eSpeak check).
Runtime & Infrastructure
Web/Resgrid.Web.Tts/Dockerfile, Web/Resgrid.Web.Tts/k8s/deployment.yaml, Web/Resgrid.Web.Tts/Resgrid.Web.Tts.csproj
Dockerfile now installs Piper binary, ffmpeg and downloads multiple prebuilt Piper models into /usr/local/share/piper-voices; k8s ConfigMap keys updated to Piper executable/model directory; csproj description updated to Piper.
Text Preprocessor Comments
Web/Resgrid.Web.Tts/Services/ITextPreprocessor.cs, Web/Resgrid.Web.Tts/Services/TextPreprocessor.cs
Documentation/comments updated to reference the TTS engine/Piper instead of eSpeak; no behavioral changes.
Tests & Expectations
Tests/Resgrid.Tests/Web/Tts/TtsServiceTests.cs, Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs
Updated unit tests to expect Piper model filenames and adjusted length-scale/speed expectations; replaced eSpeak start-info tests with Piper start-info tests; renamed a punctuation test to be TTS-generic.

WeatherAlertService: per-recipient communication

Layer / File(s) Summary
Dependency & Constructor
Core/Resgrid.Services/WeatherAlertService.cs
Constructor now takes ICommunicationService (replaces IMessageService) and assigns _communicationService.
Notification Sending Flow
Core/Resgrid.Services/WeatherAlertService.cs
Replaced single aggregated Message send with per-recipient loop that constructs a Message per eligible member and calls _communicationService.SendMessageAsync; skips sender and disabled/deleted users; notification state updates preserved.

Billing & Data-Shape Null Safety

Layer / File(s) Summary
Plan Null Guards
Core/Resgrid.Services/LimitsService.cs, Core/Resgrid.Services/SubscriptionsService.cs
Added plan != null guard before checking plan.PlanId; expanded response.Data null-check to include response.Data.Data == null to avoid dereferencing missing nested payloads.

Tests — Mocking, In-Memory Fixtures, and Resilience

Layer / File(s) Summary
Action Logs In-Memory Repository
Tests/Resgrid.Tests/Services/ActionLogsServiceTests.cs
Introduced _savedLogs and _nextLogId; repo mocks now persist to and query from in-memory list; tests removed [Ignore] and use mocked service for creating/querying/deleting logs.
AuthorizationService Unitization
Tests/Resgrid.Tests/Services/AuthorizationServiceTests.cs
Tests now instantiate AuthorizationService directly with extensive Mock<...> dependencies and per-fixture [SetUp] stubs instead of requiring DB-resolved service.
WeatherAlertService Tests
Tests/Resgrid.Tests/Services/WeatherAlertServiceTests.cs
Switched test fixture to mock ICommunicationService instead of IMessageService and pass it into service under test.
Timezone Test Robustness
Tests/Resgrid.Tests/Models/TimeZoneTests.cs
Platform-aware timezone lookup: on TimeZoneNotFoundException attempt Windows→IANA conversion and accumulate unresolved IDs for reporting rather than ignoring.

Project Documentation Replacement

Layer / File(s) Summary
Developer Guide Replace
CLAUDE.md
Replaced the prior dual-graph/context policy doc with a Resgrid project guide: solution structure, build configs, architecture/conventions (Autofac), config/caching/logging patterns, worker patterns, common gotchas, key file index, and common grep/build tasks.

XML Docs Normalization

Layer / File(s) Summary
Regenerated docs
Web/Resgrid.Web.Services/Resgrid.Web.Services.xml
XML documentation reflow/whitespace/line-wrapping changes only; no API signature changes.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant WebTtsService as Resgrid.Web.Tts
    participant PiperBinary as /usr/local/bin/piper
    participant ModelStore as /usr/local/share/piper-voices
    participant Ffmpeg

    Client->>WebTtsService: Request synthesize(text, voice, speed)
    WebTtsService->>WebTtsService: Preprocess text
    WebTtsService->>WebTtsService: Resolve model (VoiceModelMap) and compute length-scale
    WebTtsService->>PiperBinary: Start piper --model ModelFile --output_file tmp.wav --length-scale X
    PiperBinary->>ModelStore: Load ModelFile.onnx
    PiperBinary-->>WebTtsService: Write tmp.wav
    WebTtsService->>Ffmpeg: Normalize tmp.wav -> normalized.wav
    WebTtsService-->>Client: Return normalized audio URL / stream
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • Resgrid/Core#328: Modifies WeatherAlertService.SendPendingNotificationsAsync with per-recipient sending and skip logic — touches the same function changed here.
  • Resgrid/Core#355: Adjusts Dockerfile build steps; related to this PR’s Dockerfile changes that switch the TTS runtime to Piper and download models.
  • Resgrid/Core#356: Changes TtsConfig defaults (e.g., DefaultVoice); overlaps with this PR’s TTS config shape changes (PiperExecutable/PiperModelDirectory).
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 9.52% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'RE1-T115 Changing TTS voice provider' clearly and specifically summarizes the main change: replacing the TTS engine from eSpeak/MBROLA to Piper across configuration, services, tests, Docker, and Kubernetes deployment.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch develop

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 0/1 reviews remaining, refill in 60 minutes.

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Core/Resgrid.Services/SubscriptionsService.cs (1)

92-119: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

GetPlanCountsForDepartmentAsync lacks exception handling — unhandled exceptions propagate to callers.

Unlike GetCurrentPlanForDepartmentAsync (lines 59–86), which wraps the HTTP call in a try-catch, this method has no exception handling. Any network failure, DNS error, or deserialization exception will propagate unhandled to callers such as ValidateDepartmentIsWithinLimitsAsync and CanDepartmentAddNewUnit in LimitsService. Combined with the 200-second MaxTimeout (vs. the 5-second timeout on the plan-fetch method), this can cause long-blocking or unhandled exceptions on request threads.

🛡️ Suggested fix — add try-catch consistent with `GetCurrentPlanForDepartmentAsync`
-            var response = await client.ExecuteAsync<GetPlanCountsForDepartmentResult>(request);
-
-            if (response.StatusCode == HttpStatusCode.NotFound)
-                return new DepartmentPlanCount();
-
-            if (response.Data == null || response.Data.Data == null)
-                return new DepartmentPlanCount();
-
-            return response.Data.Data;
+            try
+            {
+                var response = await client.ExecuteAsync<GetPlanCountsForDepartmentResult>(request);
+
+                if (response.StatusCode == HttpStatusCode.NotFound)
+                    return new DepartmentPlanCount();
+
+                if (response.Data == null || response.Data.Data == null)
+                    return new DepartmentPlanCount();
+
+                return response.Data.Data;
+            }
+            catch (Exception ex)
+            {
+                Framework.Logging.LogException(ex);
+                return new DepartmentPlanCount();
+            }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Core/Resgrid.Services/SubscriptionsService.cs` around lines 92 - 119,
GetPlanCountsForDepartmentAsync currently makes the RestClient call without any
try/catch so network/DNS/deserialization failures (and the long 200s MaxTimeout)
will surface to callers; wrap the HTTP call and response handling in a try/catch
like GetCurrentPlanForDepartmentAsync, log the exception (use your existing
logger), and return a new DepartmentPlanCount() on any exception; while
updating, consider aligning MaxTimeout with the shorter timeout used in
GetCurrentPlanForDepartmentAsync to avoid long blocking.
🧹 Nitpick comments (1)
Core/Resgrid.Services/SubscriptionsService.cs (1)

141-145: ⚡ Quick win

~20 other return response.Data.Data sites in this file are not guarded against response.Data.Data == null.

The fix applied on lines 77 and 112–113 addresses the same NPE risk that exists in many other methods: GetCurrentPaymentForDepartmentAsync (line 144), GetPreviousNonFreePaymentForDepartmentAsync (line 195), GetUpcomingPaymentForDepartmentAsync (line 230), GetPlanByIdAsync (line 293), GetPlanByExternalIdAsync (line 332), and more. Callers of plan-returning methods in particular may access .PlanId on the result without null-checking.

Consider applying || response.Data.Data == null consistently across all similar null-guard checks in this class. Based on learnings: response.Data.Data can be null even when the API succeeds and response.Data is non-null.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Core/Resgrid.Services/SubscriptionsService.cs` around lines 141 - 145,
Several methods (e.g., GetCurrentPaymentForDepartmentAsync,
GetPreviousNonFreePaymentForDepartmentAsync,
GetUpcomingPaymentForDepartmentAsync, GetPlanByIdAsync, GetPlanByExternalIdAsync
and other similar return sites) currently return response.Data.Data without
guarding against response.Data.Data being null; add the same null-guard pattern
used earlier (check response == null || response.Data == null ||
response.Data.Data == null) before returning so these methods return null when
response.Data.Data is null to avoid NPEs when callers access fields like
.PlanId.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@CLAUDE.md`:
- Line 50: Update the misspelled test project name entry in CLAUDE.md: replace
the string "Resgrid.Intergration.Tests/" with the correct
"Resgrid.Integration.Tests/" so the project name is spelled consistently (look
for the line containing Resgrid.Intergration.Tests/ in CLAUDE.md and correct the
typo).
- Around line 69-71: Update the fenced code block that contains the architecture
line "Config  →  Model  →  Services  →  Repositories/Providers  →  Web/Workers"
to include a language identifier (e.g., change ``` to ```text) so the block is
flagged as plain text and satisfies markdownlint MD040.
- Around line 9-53: The fenced code block in CLAUDE.md is missing a language
identifier, triggering markdownlint MD040; update the opening triple-backtick
for the directory tree to include a language (e.g., add "text" so it reads
```text) so the block is properly identified and the linter error is resolved.

In `@Web/Resgrid.Web.Tts/Dockerfile`:
- Around line 24-37: The Dockerfile currently only provisions
en_US-norman-medium.onnx but AudioProcessingService.cs (and tests) reference
additional voice models (e.g., fr_FR-siwis-medium.onnx and
en_GB-semaine-medium.onnx); update the Dockerfile to download each required
.onnx and its matching .onnx.json into /usr/local/share/piper-voices (reuse the
same curl + -o pattern used for en_US-norman-medium.onnx and
en_US-norman-medium.onnx.json, and keep the ARG PIPER_VERSION-based steps
unchanged), or alternatively remove/limit the voice entries in
AudioProcessingService.cs so only models that exist in the image are resolved.
Ensure filenames exactly match the names resolved by AudioProcessingService.cs.

---

Outside diff comments:
In `@Core/Resgrid.Services/SubscriptionsService.cs`:
- Around line 92-119: GetPlanCountsForDepartmentAsync currently makes the
RestClient call without any try/catch so network/DNS/deserialization failures
(and the long 200s MaxTimeout) will surface to callers; wrap the HTTP call and
response handling in a try/catch like GetCurrentPlanForDepartmentAsync, log the
exception (use your existing logger), and return a new DepartmentPlanCount() on
any exception; while updating, consider aligning MaxTimeout with the shorter
timeout used in GetCurrentPlanForDepartmentAsync to avoid long blocking.

---

Nitpick comments:
In `@Core/Resgrid.Services/SubscriptionsService.cs`:
- Around line 141-145: Several methods (e.g.,
GetCurrentPaymentForDepartmentAsync,
GetPreviousNonFreePaymentForDepartmentAsync,
GetUpcomingPaymentForDepartmentAsync, GetPlanByIdAsync, GetPlanByExternalIdAsync
and other similar return sites) currently return response.Data.Data without
guarding against response.Data.Data being null; add the same null-guard pattern
used earlier (check response == null || response.Data == null ||
response.Data.Data == null) before returning so these methods return null when
response.Data.Data is null to avoid NPEs when callers access fields like
.PlanId.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 26f860c2-9576-4175-bbc8-a8fc32b72a1f

📥 Commits

Reviewing files that changed from the base of the PR and between 6ebc6b8 and 677aed8.

📒 Files selected for processing (15)
  • CLAUDE.md
  • Core/Resgrid.Config/TtsConfig.cs
  • Core/Resgrid.Services/LimitsService.cs
  • Core/Resgrid.Services/SubscriptionsService.cs
  • Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs
  • Tests/Resgrid.Tests/Web/Tts/TtsServiceTests.cs
  • Web/Resgrid.Web.Tts/Configuration/ServiceCollectionExtensions.cs
  • Web/Resgrid.Web.Tts/Configuration/TtsOptions.cs
  • Web/Resgrid.Web.Tts/Dockerfile
  • Web/Resgrid.Web.Tts/Health/TtsDependencyHealthCheck.cs
  • Web/Resgrid.Web.Tts/Resgrid.Web.Tts.csproj
  • Web/Resgrid.Web.Tts/Services/AudioProcessingService.cs
  • Web/Resgrid.Web.Tts/Services/ITextPreprocessor.cs
  • Web/Resgrid.Web.Tts/Services/TextPreprocessor.cs
  • Web/Resgrid.Web.Tts/k8s/deployment.yaml

Comment thread CLAUDE.md
Comment on lines +9 to +53
```
Resgrid.sln
├── Web/ # ASP.NET web apps
│ ├── Resgrid.Web/ # Main MVC web application
│ ├── Resgrid.Web.Services/ # REST API (v4 controllers)
│ ├── Resgrid.Web.Eventing/ # Webhook/event endpoint
│ ├── Resgrid.Web.Mcp/ # MCP endpoint
│ └── Resgrid.Web.Tts/ # Text-to-speech
├── Core/ # Core business logic
│ ├── Resgrid.Config/ # Static config classes (one per domain)
│ ├── Resgrid.Framework/ # Utilities: Logging, Serialization, Hashing
│ ├── Resgrid.Localization/ # Localization strings
│ ├── Resgrid.Model/ # Entities, enums, interfaces (Services, Repositories, Providers)
│ └── Resgrid.Services/ # Service implementations
├── Repositories/ # Data access
│ ├── Resgrid.Repositories.DataRepository/ # SQL Server / Dapper
│ └── Resgrid.Repositories.NoSqlRepository/ # MongoDB
├── Providers/ # Infrastructure implementations
│ ├── Resgrid.Providers.Cache/ # Redis caching (AzureRedisCacheProvider)
│ ├── Resgrid.Providers.Bus/ # Azure Service Bus
│ ├── Resgrid.Providers.Bus.Rabbit/ # RabbitMQ alternative
│ ├── Resgrid.Providers.Email/ # Email delivery
│ ├── Resgrid.Providers.Geo/ # Geolocation
│ ├── Resgrid.Providers.Marketing/ # Marketing/CRM
│ ├── Resgrid.Providers.Messaging/ # Push notifications
│ ├── Resgrid.Providers.Migrations/ # SQL Server migrations
│ ├── Resgrid.Providers.MigrationsPg/# PostgreSQL migrations
│ ├── Resgrid.Providers.Number/ # Phone number provisioning
│ ├── Resgrid.Providers.Pdf/ # PDF generation
│ ├── Resgrid.Providers.Voip/ # VoIP/SIP
│ ├── Resgrid.Providers.Weather/ # Weather data
│ ├── Resgrid.Providers.Workflow/ # Workflow execution
│ ├── Resgrid.Providers.Claims/ # Custom auth claims
│ └── Resgrid.Providers.AddressVerification/
├── Workers/ # Background job processing
│ ├── Resgrid.Workers.Framework/ # Worker logic + Bootstrapper
│ ├── Resgrid.Workers.Console/ # Worker host (console app)
│ └── Support/Quidjibo.Postgres/ # Queue backend for PostgreSQL
├── Tests/ # Test projects
│ ├── Resgrid.Tests/
│ ├── Resgrid.SmokeTests/
│ └── Resgrid.Intergration.Tests/
└── Tools/
└── Resgrid.Console/ # Admin CLI tools
```
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add a language identifier to this fenced code block.

This block triggers markdownlint MD040. Use text (or bash/plaintext as appropriate) after the opening fence.

Suggested fix
-```
+```text
 Resgrid.sln
 ├── Web/                          # ASP.NET web apps
 ...
 └── Tools/
     └── Resgrid.Console/          # Admin CLI tools
</details>

<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion

🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 9-9: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CLAUDE.md` around lines 9 - 53, The fenced code block in CLAUDE.md is missing
a language identifier, triggering markdownlint MD040; update the opening
triple-backtick for the directory tree to include a language (e.g., add "text"
so it reads ```text) so the block is properly identified and the linter error is
resolved.

Comment thread CLAUDE.md
├── Tests/ # Test projects
│ ├── Resgrid.Tests/
│ ├── Resgrid.SmokeTests/
│ └── Resgrid.Intergration.Tests/
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix typo in test project name.

Resgrid.Intergration.Tests appears misspelled; should be Resgrid.Integration.Tests (assuming standard naming).

Suggested fix
-│   └── Resgrid.Intergration.Tests/
+│   └── Resgrid.Integration.Tests/
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
│ └── Resgrid.Intergration.Tests/
│ └── Resgrid.Integration.Tests/
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CLAUDE.md` at line 50, Update the misspelled test project name entry in
CLAUDE.md: replace the string "Resgrid.Intergration.Tests/" with the correct
"Resgrid.Integration.Tests/" so the project name is spelled consistently (look
for the line containing Resgrid.Intergration.Tests/ in CLAUDE.md and correct the
typo).

Comment thread CLAUDE.md
Comment on lines +69 to +71
```
Config → Model → Services → Repositories/Providers → Web/Workers
```
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add a language identifier to this fenced code block.

This also triggers markdownlint MD040; set a language for the architecture diagram block.

Suggested fix
-```
+```text
 Config  →  Model  →  Services  →  Repositories/Providers  →  Web/Workers
</details>

<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion

🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 69-69: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CLAUDE.md` around lines 69 - 71, Update the fenced code block that contains
the architecture line "Config  →  Model  →  Services  →  Repositories/Providers 
→  Web/Workers" to include a language identifier (e.g., change ``` to ```text)
so the block is flagged as plain text and satisfies markdownlint MD040.

Comment thread Web/Resgrid.Web.Tts/Dockerfile
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Core/Resgrid.Services/WeatherAlertService.cs (1)

379-419: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't mark the alert notified after a partial send failure.

A single SendMessageAsync exception stops the loop, skips the remaining recipients, and the code still sets NotificationSent = true afterward. That turns transient per-user failures into permanent missed notifications.

Suggested fix
-						try
-						{
-							var members = await _departmentsService.GetAllMembersForDepartmentAsync(departmentId);
-							if (members != null && members.Any())
-							{
-								// Use department managing user as sender for notifications
-								var senderId = department?.ManagingUserId ?? members.First().UserId;
-
-								var subject = FormatAlertSubject(alert);
-								var body = FormatAlertMessageBody(alert, department);
-
-								foreach (var member in members)
-								{
-									if (member.UserId == senderId || member.IsDisabled.GetValueOrDefault() || member.IsDeleted)
-										continue;
-
-									var notifyMsg = new Message
-									{
-										Subject = subject,
-										Body = body,
-										SendingUserId = senderId,
-										ReceivingUserId = member.UserId,
-										SentOn = DateTime.UtcNow,
-										SystemGenerated = true,
-										IsBroadcast = true,
-										Type = 0
-									};
-
-									await _communicationService.SendMessageAsync(notifyMsg, "Weather Alert System", null, departmentId, null, department);
-								}
-							}
-						}
-						catch (Exception ex)
-						{
-							Logging.LogException(ex);
-						}
+						var sendSucceeded = true;
+						try
+						{
+							var members = await _departmentsService.GetAllMembersForDepartmentAsync(departmentId);
+							if (members != null && members.Any())
+							{
+								var senderId = department?.ManagingUserId ?? members.First().UserId;
+								var subject = FormatAlertSubject(alert);
+								var body = FormatAlertMessageBody(alert, department);
+
+								foreach (var member in members)
+								{
+									if (member.UserId == senderId || member.IsDisabled.GetValueOrDefault() || member.IsDeleted)
+										continue;
+
+									try
+									{
+										var notifyMsg = new Message
+										{
+											Subject = subject,
+											Body = body,
+											SendingUserId = senderId,
+											ReceivingUserId = member.UserId,
+											SentOn = DateTime.UtcNow,
+											SystemGenerated = true,
+											IsBroadcast = true,
+											Type = 0
+										};
+
+										await _communicationService.SendMessageAsync(notifyMsg, "Weather Alert System", null, departmentId, null, department);
+									}
+									catch (Exception ex)
+									{
+										sendSucceeded = false;
+										Logging.LogException(ex);
+									}
+								}
+							}
+						}
+						catch (Exception ex)
+						{
+							sendSucceeded = false;
+							Logging.LogException(ex);
+						}
+
+						if (!sendSucceeded)
+							continue;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Core/Resgrid.Services/WeatherAlertService.cs` around lines 379 - 419, The
current try/catch around the entire member-send block causes a single
SendMessageAsync exception to abort sending to remaining recipients while still
setting alert.NotificationSent = true; change this so failures are handled
per-recipient: inside the WeatherAlertService method that calls
_departmentsService.GetAllMembersForDepartmentAsync and loops members, introduce
a bool allSent = true; move the try/catch into the foreach around the call to
_communicationService.SendMessageAsync so each SendMessageAsync error is caught,
logged (Logging.LogException(ex)), sets allSent = false and continues to the
next member; after the loop set alert.NotificationSent = allSent (do not mark
true on partial failure).
🧹 Nitpick comments (3)
Tests/Resgrid.Tests/Models/TimeZoneTests.cs (1)

45-57: ⚡ Quick win

Consider a platform-conditional lower-bound assertion to keep the test meaningful.

Currently the test passes unconditionally regardless of how many zones failed to resolve — including the case where every zone is unresolvable (e.g., a minimal Linux container without tzdata). Adding a guard for the Windows case ensures the test still enforces something concrete:

♻️ Suggested improvement
 if (unresolvedZones.Count > 0)
 {
     TestContext.WriteLine(
         $"The following {unresolvedZones.Count} timezone(s) could not be resolved on this platform: " +
         string.Join(", ", unresolvedZones));
 }

+// On Windows, all configured timezones must be resolvable.
+if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(
+        System.Runtime.InteropServices.OSPlatform.Windows))
+{
+    unresolvedZones.Should().BeEmpty(
+        "all configured timezone IDs should resolve on Windows");
+}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Tests/Resgrid.Tests/Models/TimeZoneTests.cs` around lines 45 - 57, Add a
platform-conditional lower-bound assertion so the test fails if every timezone
is unresolvable on Windows: after the TestContext.WriteLine block, detect
Windows (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) and assert that
unresolvedZones.Count is less than the total number of zones (or not equal to
the total) so the test enforces at least one resolvable zone; reference
unresolvedZones and the existing TestContext.WriteLine block when adding the
conditional assertion.
Tests/Resgrid.Tests/Services/ActionLogsServiceTests.cs (1)

60-67: ⚡ Quick win

Make the fake repository update existing IDs instead of always appending.

SaveOrUpdateAsync currently adds the log every time, so any update path will create duplicates and make later retrieval/deletion behavior diverge from the real repository.

Suggested fix
 				_actionLogsRepositoryMock.Setup(m => m.SaveOrUpdateAsync(It.IsAny<ActionLog>(), It.IsAny<CancellationToken>(), It.IsAny<bool>()))
 					.ReturnsAsync((ActionLog al, CancellationToken ct, bool firstLevel) =>
 					{
 						if (al.ActionLogId <= 0)
-							al.ActionLogId = _nextLogId++;
-						_savedLogs.Add(al);
+						{
+							al.ActionLogId = _nextLogId++;
+							_savedLogs.Add(al);
+						}
+						else
+						{
+							var index = _savedLogs.FindIndex(x => x.ActionLogId == al.ActionLogId);
+							if (index >= 0)
+								_savedLogs[index] = al;
+							else
+								_savedLogs.Add(al);
+						}
 						return al;
 					});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Tests/Resgrid.Tests/Services/ActionLogsServiceTests.cs` around lines 60 - 67,
The fake repository's SaveOrUpdateAsync currently always appends, causing
duplicates; modify the Setup lambda for SaveOrUpdateAsync so that if
al.ActionLogId <= 0 it assigns a new id (using _nextLogId++) and adds to
_savedLogs, else locate an existing entry in _savedLogs by ActionLogId and
update/replace that entry (or add if not found), then return the updated
ActionLog; reference SaveOrUpdateAsync, _savedLogs, ActionLog.ActionLogId and
_nextLogId when making this change.
Tests/Resgrid.Tests/Services/WeatherAlertServiceTests.cs (1)

30-62: ⚡ Quick win

Add coverage for the new per-recipient notification flow.

This fixture only tracks the constructor swap. The behavior change in this PR is that SendPendingNotificationsAsync now sends via ICommunicationService once per eligible member, so this file should verify the call count and the skip rules for sender/disabled/deleted users.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Tests/Resgrid.Tests/Services/WeatherAlertServiceTests.cs` around lines 30 -
62, The test fixture in WeatherAlertServiceTests doesn't assert the new
per-recipient notification behavior introduced in
WeatherAlertService.SendPendingNotificationsAsync; update the tests to exercise
SendPendingNotificationsAsync and verify that
ICommunicationService.SendNotificationAsync (or the relevant method on
ICommunicationService) is invoked once per eligible member and skipped for
sender/disabled/deleted users. Specifically, use the existing
_communicationServiceMock, arrange department members with varying states
(normal, sender, disabled, deleted), set up pending notifications in the
_weatherAlertRepoMock/_weatherAlertSourceRepoMock as needed, call
_weatherAlertService.SendPendingNotificationsAsync, and add assertions on
_communicationServiceMock.Verify call counts and that no calls were made for
sender/disabled/deleted members. Ensure test names cover call count and skip
rules.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Tests/Resgrid.Tests/Services/ActionLogsServiceTests.cs`:
- Around line 78-80: The mock for GetLastActionLogsForUserAsync ignores the
cutoff parameter `time`; update the lambda in _actionLogsRepositoryMock.Setup to
filter _savedLogs by both matching UserId and by the timestamp (e.g.,
l.CreatedOnUtc >= time or > time depending on test expectation) before ordering
by ActionLogId and returning FirstOrDefault(), so the mock honors the cutoff
timestamp passed into GetLastActionLogsForUserAsync.

In `@Tests/Resgrid.Tests/Services/AuthorizationServiceTests.cs`:
- Around line 316-347: The fixture is still hitting the message-auth path
because _messageServiceMock.GetMessageByIdAsync(1) is returning a Message; to
exercise the work-log auth cases for CanUserViewAndEditWorkLogAsync, change the
message mock so it does not claim the same id (either return null for id 1 or
move the message setup to a different id). Update _messageServiceMock.Setup(m =>
m.GetMessageByIdAsync(1)).ReturnsAsync((Message)null) or use a different
MessageId there, leaving _workLogsServiceMock.GetWorkLogByIdAsync(1) returning
the workLog so the work-log authorization branch
(CanUserViewAndEditWorkLogAsync) is executed.

---

Outside diff comments:
In `@Core/Resgrid.Services/WeatherAlertService.cs`:
- Around line 379-419: The current try/catch around the entire member-send block
causes a single SendMessageAsync exception to abort sending to remaining
recipients while still setting alert.NotificationSent = true; change this so
failures are handled per-recipient: inside the WeatherAlertService method that
calls _departmentsService.GetAllMembersForDepartmentAsync and loops members,
introduce a bool allSent = true; move the try/catch into the foreach around the
call to _communicationService.SendMessageAsync so each SendMessageAsync error is
caught, logged (Logging.LogException(ex)), sets allSent = false and continues to
the next member; after the loop set alert.NotificationSent = allSent (do not
mark true on partial failure).

---

Nitpick comments:
In `@Tests/Resgrid.Tests/Models/TimeZoneTests.cs`:
- Around line 45-57: Add a platform-conditional lower-bound assertion so the
test fails if every timezone is unresolvable on Windows: after the
TestContext.WriteLine block, detect Windows
(RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) and assert that
unresolvedZones.Count is less than the total number of zones (or not equal to
the total) so the test enforces at least one resolvable zone; reference
unresolvedZones and the existing TestContext.WriteLine block when adding the
conditional assertion.

In `@Tests/Resgrid.Tests/Services/ActionLogsServiceTests.cs`:
- Around line 60-67: The fake repository's SaveOrUpdateAsync currently always
appends, causing duplicates; modify the Setup lambda for SaveOrUpdateAsync so
that if al.ActionLogId <= 0 it assigns a new id (using _nextLogId++) and adds to
_savedLogs, else locate an existing entry in _savedLogs by ActionLogId and
update/replace that entry (or add if not found), then return the updated
ActionLog; reference SaveOrUpdateAsync, _savedLogs, ActionLog.ActionLogId and
_nextLogId when making this change.

In `@Tests/Resgrid.Tests/Services/WeatherAlertServiceTests.cs`:
- Around line 30-62: The test fixture in WeatherAlertServiceTests doesn't assert
the new per-recipient notification behavior introduced in
WeatherAlertService.SendPendingNotificationsAsync; update the tests to exercise
SendPendingNotificationsAsync and verify that
ICommunicationService.SendNotificationAsync (or the relevant method on
ICommunicationService) is invoked once per eligible member and skipped for
sender/disabled/deleted users. Specifically, use the existing
_communicationServiceMock, arrange department members with varying states
(normal, sender, disabled, deleted), set up pending notifications in the
_weatherAlertRepoMock/_weatherAlertSourceRepoMock as needed, call
_weatherAlertService.SendPendingNotificationsAsync, and add assertions on
_communicationServiceMock.Verify call counts and that no calls were made for
sender/disabled/deleted members. Ensure test names cover call count and skip
rules.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 0ca78753-cabb-4259-8a96-c00ef9624139

📥 Commits

Reviewing files that changed from the base of the PR and between 677aed8 and 98109a4.

📒 Files selected for processing (9)
  • Core/Resgrid.Services/WeatherAlertService.cs
  • Tests/Resgrid.Tests/Models/TimeZoneTests.cs
  • Tests/Resgrid.Tests/Services/ActionLogsServiceTests.cs
  • Tests/Resgrid.Tests/Services/AuthorizationServiceTests.cs
  • Tests/Resgrid.Tests/Services/WeatherAlertServiceTests.cs
  • Tests/Resgrid.Tests/Web/Tts/TtsServiceTests.cs
  • Web/Resgrid.Web.Services/Resgrid.Web.Services.xml
  • Web/Resgrid.Web.Tts/Dockerfile
  • Web/Resgrid.Web.Tts/Services/AudioProcessingService.cs
✅ Files skipped from review due to trivial changes (2)
  • Web/Resgrid.Web.Tts/Services/AudioProcessingService.cs
  • Web/Resgrid.Web.Services/Resgrid.Web.Services.xml
🚧 Files skipped from review as they are similar to previous changes (1)
  • Web/Resgrid.Web.Tts/Dockerfile

Comment on lines +78 to +80
_actionLogsRepositoryMock.Setup(m => m.GetLastActionLogsForUserAsync(It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<DateTime>()))
.ReturnsAsync((string userId, bool disableAuto, DateTime time) =>
_savedLogs.Where(l => l.UserId == userId).OrderByDescending(l => l.ActionLogId).FirstOrDefault());
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Honor the cutoff timestamp in the mock.

This setup ignores time, so should_get_no_log_after_hour below can only ever pass and no longer proves the one-hour filter works.

Suggested fix
 				_actionLogsRepositoryMock.Setup(m => m.GetLastActionLogsForUserAsync(It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<DateTime>()))
 					.ReturnsAsync((string userId, bool disableAuto, DateTime time) =>
-						_savedLogs.Where(l => l.UserId == userId).OrderByDescending(l => l.ActionLogId).FirstOrDefault());
+						_savedLogs
+							.Where(l => l.UserId == userId && l.Timestamp >= time)
+							.OrderByDescending(l => l.ActionLogId)
+							.FirstOrDefault());
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Tests/Resgrid.Tests/Services/ActionLogsServiceTests.cs` around lines 78 - 80,
The mock for GetLastActionLogsForUserAsync ignores the cutoff parameter `time`;
update the lambda in _actionLogsRepositoryMock.Setup to filter _savedLogs by
both matching UserId and by the timestamp (e.g., l.CreatedOnUtc >= time or >
time depending on test expectation) before ordering by ActionLogId and returning
FirstOrDefault(), so the mock honors the cutoff timestamp passed into
GetLastActionLogsForUserAsync.

Comment on lines +316 to +347
[SetUp]
public void Setup()
{
// TestUser1Id = managing user and admin
// TestUser4Id = regular user not an admin
var dept1 = CreateDepartmentWithAdmins(1, TestData.Users.TestUser1Id, TestData.Users.TestUser1Id);
_departmentsServiceMock.Setup(m => m.GetDepartmentByUserIdAsync(TestData.Users.TestUser1Id, It.IsAny<bool>())).ReturnsAsync(dept1);
_departmentsServiceMock.Setup(m => m.GetDepartmentByUserIdAsync(TestData.Users.TestUser4Id, It.IsAny<bool>())).ReturnsAsync(dept1);

// WorkLog 1 belongs to department 1, logged by TestUser1Id
var workLog = new Log
{
LogId = 1,
DepartmentId = 1,
LoggedByUserId = TestData.Users.TestUser1Id,
Users = new System.Collections.Generic.List<LogUser>()
};
_workLogsServiceMock.Setup(m => m.GetWorkLogByIdAsync(1)).ReturnsAsync(workLog);
_departmentsServiceMock.Setup(m => m.GetDepartmentByIdAsync(1, It.IsAny<bool>())).ReturnsAsync(dept1);

// Message setups for the view message tests
_messageServiceMock.Setup(m => m.GetMessageByIdAsync(1))
.ReturnsAsync(new Message
{
MessageId = 1,
SendingUserId = TestData.Users.TestUser1Id,
MessageRecipients = new System.Collections.Generic.List<MessageRecipient>
{
new MessageRecipient { UserId = TestData.Users.TestUser2Id }
}
});
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

This fixture is still wired for message auth, not work-log auth.

The added setup for GetMessageByIdAsync keeps the last two cases on the message path, so this fixture still doesn't cover the creator/non-admin scenarios for CanUserViewAndEditWorkLogAsync.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Tests/Resgrid.Tests/Services/AuthorizationServiceTests.cs` around lines 316 -
347, The fixture is still hitting the message-auth path because
_messageServiceMock.GetMessageByIdAsync(1) is returning a Message; to exercise
the work-log auth cases for CanUserViewAndEditWorkLogAsync, change the message
mock so it does not claim the same id (either return null for id 1 or move the
message setup to a different id). Update _messageServiceMock.Setup(m =>
m.GetMessageByIdAsync(1)).ReturnsAsync((Message)null) or use a different
MessageId there, leaving _workLogsServiceMock.GetWorkLogByIdAsync(1) returning
the workLog so the work-log authorization branch
(CanUserViewAndEditWorkLogAsync) is executed.

@ucswift
Copy link
Copy Markdown
Member Author

ucswift commented May 4, 2026

Approve

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR is approved.

@ucswift ucswift merged commit 1672464 into master May 4, 2026
18 of 19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant