diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh
index b1840f77c..242943eec 100644
--- a/.devcontainer/post-create.sh
+++ b/.devcontainer/post-create.sh
@@ -144,11 +144,11 @@ fi
echo "Installing PowerShell modules..."
pwsh -Command 'Install-Module HtmlToMarkdown -AcceptLicense -Force'
pwsh -Command 'Install-Module Pester -Force -SkipPublisherCheck -MinimumVersion "5.0.0" -Scope CurrentUser'
-# Az.Accounts + Az.Resources are the only Az sub-modules needed for Deploy-Infrastructure.ps1
-# (New-AzDeployment, Test-AzDeployment, Get-AzContext). Installing just these avoids pulling
-# the full Az bundle (~80 modules).
+# Install only the Az sub-modules required by the deployment scripts.
+# Az.PostgreSql is needed for Get-AzPostgreSqlFlexibleServerFirewallRule in Deploy-Infrastructure.ps1.
pwsh -Command 'Install-Module Az.Accounts -Force -Scope CurrentUser'
pwsh -Command 'Install-Module Az.Resources -Force -Scope CurrentUser'
+pwsh -Command 'Install-Module Az.PostgreSql -Force -Scope CurrentUser'
# ==================== PowerShell Profile ====================
echo "Setting up PowerShell profile..."
diff --git a/.github/prompts/address-th-review-comments.prompt.md b/.github/prompts/address-th-review-comments.prompt.md
index d1ddf585d..20508ac6d 100644
--- a/.github/prompts/address-th-review-comments.prompt.md
+++ b/.github/prompts/address-th-review-comments.prompt.md
@@ -1,10 +1,10 @@
---
name: address-th-review-comments
-description: "Reviews all open review comment threads on the current branch's pull request, analyses each one, applies code fixes where needed, replies to each thread explaining what was done or why it was ignored, resolves each thread after replying, then commits and pushes directly."
+description: "Reviews all open review comment threads, CodeQL code scanning alerts, and GitHub Advanced Security alerts on the current branch's pull request, analyses each one, applies code fixes where needed, replies to each thread explaining what was done or why it was ignored, resolves each thread after replying, then commits and pushes directly."
model: Claude Sonnet 4.6
---
-# Address PR Review Comments
+# Address PR Review Comments, CodeQL Alerts, and Advanced Security Alerts
**π¨ CRITICAL**: Read this entire prompt from start to finish before executing any step.
@@ -103,7 +103,9 @@ Store the result as `[REPO]` (format: `owner/repo`).
---
-## Step 5 β Fetch all open (unresolved) review threads
+## Step 5 β Fetch all open issues (review threads + security alerts)
+
+### 5a β Fetch unresolved review threads
Run the following GraphQL query to retrieve all unresolved review threads and their comments. Replace `[OWNER]`, `[REPONAME]`, and `[PR_NUMBER]` with the actual values (split `[REPO]` on `/`):
@@ -171,21 +173,55 @@ gh api graphql -f query='
' | Out-File -FilePath ".tmp/pr-review-threads.json" -Encoding utf8
```
-Parse the file. Filter to threads where `isResolved` is `false`. If there are no unresolved threads, inform the user:
+Parse the file. Filter to threads where `isResolved` is `false`. Count them and store as `[THREAD_COUNT]`.
+
+### 5b β Fetch open code scanning (CodeQL) alerts
+
+Fetch all open code scanning alerts for the branch:
+
+```pwsh
+gh api "repos/[REPO]/code-scanning/alerts?ref=refs/heads/[BRANCHNAME]&state=open&per_page=100" | Out-File -FilePath ".tmp/pr-codeql-alerts.json" -Encoding utf8
+```
+
+Parse the file. Each alert has: `number`, `rule.id`, `rule.description`, `rule.severity`, `most_recent_instance.location` (`path`, `start_line`, `end_line`), `most_recent_instance.message.text`, and `html_url`.
+
+If the command fails (e.g., code scanning is not enabled), record 0 alerts and continue.
+
+Count the open alerts and store as `[CODEQL_COUNT]`.
+
+### 5c β Fetch open secret scanning alerts
+
+Fetch all open secret scanning alerts:
+
+```pwsh
+gh api "repos/[REPO]/secret-scanning/alerts?state=open&per_page=100" | Out-File -FilePath ".tmp/pr-secret-alerts.json" -Encoding utf8
+```
+
+Parse the file. Each alert has: `number`, `secret_type_display_name`, `resolution`, `html_url`, and `locations_url`.
+
+If the command fails (e.g., secret scanning is not enabled), record 0 alerts and continue.
+
+Count the open alerts and store as `[SECRET_COUNT]`.
+
+### 5d β Summarise what was found
-> All review threads on PR #[PR_NUMBER] are already resolved. Nothing to do.
+If ALL three counts are zero, inform the user:
-Then skip directly to Step 9.
+> No open review threads, CodeQL alerts, or secret scanning alerts found on PR #[PR_NUMBER]. Nothing to do.
-Otherwise, count the unresolved threads and report:
+Then skip directly to Step 10.
-> Found [N] unresolved review thread(s) to address.
+Otherwise report:
-**CHECKPOINT**: "β Step 5 completed. Found [N] unresolved thread(s). Moving to Step 6."
+> Found [THREAD_COUNT] unresolved review thread(s), [CODEQL_COUNT] open CodeQL alert(s), and [SECRET_COUNT] open secret scanning alert(s) to address.
+
+**CHECKPOINT**: "β Step 5 completed. [THREAD_COUNT] thread(s), [CODEQL_COUNT] CodeQL alert(s), [SECRET_COUNT] secret alert(s). Moving to Step 6."
---
-## Step 6 β Analyse and address each open thread
+## Step 6 β Analyse and address each open review thread
+
+If `[THREAD_COUNT]` is 0, skip this step entirely.
**π¨ CRITICAL**: Steps 6d (reply) and 6e (resolve) are **MANDATORY** for **every single thread** β including threads where you made a code fix. You are not done with a thread until you have BOTH posted a reply AND resolved it on GitHub. Never skip 6d or 6e. Never batch them for later.
@@ -262,11 +298,132 @@ Confirm both actions completed, then state:
Repeat steps 6aβ6f for every unresolved thread before moving on.
-**CHECKPOINT**: "β Step 6 completed. All [N] threads replied to and resolved on GitHub. Moving to Step 7."
+**CHECKPOINT**: "β Step 6 completed. All [THREAD_COUNT] threads replied to and resolved on GitHub. Moving to Step 7."
+
+---
+
+## Step 7 β Analyse and address each open CodeQL alert
+
+If `[CODEQL_COUNT]` is 0, skip this step entirely.
+
+Work through each open CodeQL alert one at a time. **Complete all sub-steps before moving to the next alert.**
+
+### 7a β Read and understand the alert
+
+For each alert, read:
+
+- **Rule**: `rule.id` and `rule.description` β what vulnerability or code quality issue was detected
+- **Severity**: `rule.severity` (e.g., `error`, `warning`, `note`)
+- **Location**: `most_recent_instance.location.path` and `start_line` β the exact file and line
+- **Message**: `most_recent_instance.message.text` β the specific diagnostic message
+
+Read the relevant file around the flagged line to understand the current code in context.
+
+### 7b β Decide: fix or dismiss
+
+**NEEDS A FIX** β The alert points to a genuine security issue, vulnerability, or code defect that should be corrected (e.g., SQL injection risk, unvalidated input, exposed secret, use of a deprecated insecure API).
+
+**DISMISS** β The alert is a false positive, the code path is unreachable, the risk is mitigated elsewhere, or the flagged pattern is an intentional and safe design choice.
+
+**π¨ CRITICAL**: For `error`-severity alerts, default to fixing unless there is a clear, well-reasoned case for dismissal.
+
+### 7c β If NEEDS A FIX: apply the fix
+
+Make the minimal correct change to resolve the CodeQL finding. Follow the conventions in the relevant `AGENTS.md` files. Run `get_errors` after editing to ensure no new errors were introduced.
+
+### 7d β **MANDATORY**: Dismiss or note the alert on GitHub
+
+**If you fixed the code**: The alert will auto-close when the fix is pushed. No API call needed here β just make a note that this alert was fixed in code.
+
+**If dismissing (false positive / won't fix)**: Dismiss the alert via the API:
+
+```pwsh
+gh api repos/[REPO]/code-scanning/alerts/[ALERT_NUMBER] -X PATCH -f state="dismissed" -f dismissed_reason="false positive" -f dismissed_comment="[One or two sentences explaining why this is a false positive or won't be fixed, referencing the specific code path or mitigation]"
+```
+
+Valid `dismissed_reason` values: `"false positive"`, `"won't fix"`, `"used in tests"`.
+
+Verify the command exits 0. If it fails, stop and report the error.
+
+### 7e β Checkpoint for this alert
+
+State:
+
+"β CodeQL alert [N/TOTAL] (#[ALERT_NUMBER] β [rule.id]) β [FIXED in code / DISMISSED]. [Brief one-line summary of action taken]."
---
-## Step 7 β Verify no new errors
+Repeat steps 7aβ7e for every open CodeQL alert before moving on.
+
+**CHECKPOINT**: "β Step 7 completed. All [CODEQL_COUNT] CodeQL alerts addressed. Moving to Step 8."
+
+---
+
+## Step 8 β Analyse and address each open secret scanning alert
+
+If `[SECRET_COUNT]` is 0, skip this step entirely.
+
+Work through each open secret scanning alert one at a time.
+
+### 8a β Read and understand the alert
+
+For each alert:
+
+- Note the `secret_type_display_name` (e.g., "GitHub Personal Access Token")
+- Fetch the alert locations to find where in the codebase the secret appears:
+
+ ```pwsh
+ gh api [LOCATIONS_URL]
+ ```
+
+- Identify the file(s) and line(s) involved.
+
+### 8b β Decide: rotate or dismiss
+
+**ROTATE / REMEDIATE** β The secret is a real credential or token that should not be in source code. The correct fix is to:
+
+1. Remove the secret from the file (replace with an environment variable reference, a secrets manager reference, or a placeholder)
+2. Immediately rotate/revoke the actual secret in the relevant system (GitHub, Azure Key Vault, etc.) β **inform the user** that rotation is needed since you cannot do this on their behalf
+
+**DISMISS** β The value is a test fixture, a clearly fake/placeholder value, or is already rotated and the alert is stale.
+
+**π¨ CRITICAL**: Never leave a real secret in the codebase. If in doubt, treat it as real and remediate.
+
+### 8c β If ROTATE / REMEDIATE: remove the secret from code
+
+Replace the hardcoded secret with an appropriate reference (e.g., `Environment.GetEnvironmentVariable("SECRET_NAME")`, a config binding, or a Key Vault reference). Follow existing patterns in the codebase. Run `get_errors` after editing.
+
+**Then inform the user**:
+
+> β οΈ Secret `[SECRET_TYPE]` was found at `[FILE]:[LINE]`. The hardcoded value has been removed from the code. **You must manually rotate this secret** in [the relevant system] to ensure the exposed value is no longer valid.
+
+### 8d β **MANDATORY**: Resolve or dismiss the alert on GitHub
+
+**If you removed the secret from code**: The alert will auto-close when the fix is pushed. No API call needed.
+
+**If dismissing**: Dismiss via the API:
+
+```pwsh
+gh api repos/[REPO]/secret-scanning/alerts/[ALERT_NUMBER] -X PATCH -f resolution="used_in_tests" -f resolution_comment="[Brief explanation]"
+```
+
+Valid `resolution` values: `"false_positive"`, `"wont_fix"`, `"revoked"`, `"used_in_tests"`.
+
+### 8e β Checkpoint for this alert
+
+State:
+
+"β Secret alert [N/TOTAL] (#[ALERT_NUMBER] β [secret_type_display_name]) β [REMEDIATED in code / DISMISSED]. [Brief one-line summary]."
+
+---
+
+Repeat steps 8aβ8e for every open secret scanning alert before moving on.
+
+**CHECKPOINT**: "β Step 8 completed. All [SECRET_COUNT] secret scanning alerts addressed. Moving to Step 9."
+
+---
+
+## Step 9 β Verify no new errors
Run:
@@ -274,35 +431,55 @@ Run:
Run -Clean
```
-If there are build or test failures caused by changes made in Step 6, fix them before proceeding.
+If there are build or test failures caused by changes made in Steps 6, 7, or 8, fix them before proceeding.
-**CHECKPOINT**: "β Step 7 completed. No errors or failures. Moving to Step 8."
+**CHECKPOINT**: "β Step 9 completed. No errors or failures. Moving to Step 10."
---
-## Step 8 β Summarise changes for the user
+## Step 10 β Summarise changes for the user
+
+Print a concise summary with three sections:
-Print a concise table of every thread and what was done:
+**Review threads:**
| # | File | Line | Action | Summary |
-|---|------|------|--------|---------|
+|---|------|------|--------|------|
| 1 | ... | ... | Fixed / No fix | ... |
-**CHECKPOINT**: "β Step 8 completed. Summary provided. Moving to Step 9."
+**CodeQL alerts:**
+
+| # | Alert # | Rule | Severity | Action | Summary |
+|---|---------|------|----------|--------|------|
+| 1 | ... | ... | ... | Fixed / Dismissed | ... |
+
+**Secret scanning alerts:**
+
+| # | Alert # | Type | Action | Summary |
+|---|---------|------|--------|------|
+| 1 | ... | ... | Remediated / Dismissed | ... |
+
+If a category had 0 items, omit its table and note "None found."
+
+**CHECKPOINT**: "β Step 10 completed. Summary provided. Moving to Step 11."
---
-## Step 9 β Commit and push directly
+## Step 11 β Commit and push directly
-If **no code changes** were made (all threads received "no fix needed" replies), skip this step β no commit is necessary.
+If **no code changes** were made (all issues received "no fix" / "dismiss" responses), skip this step β no commit is necessary.
-Otherwise, stage and commit only the files changed in Step 6:
+Otherwise, stage all changed files:
```pwsh
git add -A
```
-Write a short, direct commit message summarising the fixes (no ticket numbers, no PR references). Use imperative mood. Example: `"Address PR review comments: [brief summary]"`.
+Write a short, direct commit message summarising the fixes (no ticket numbers, no PR references). Use imperative mood. Include all relevant issue types. Examples:
+
+- `"Address PR review comments: [brief summary]"`
+- `"Fix CodeQL alerts: [brief summary]"`
+- `"Address review comments and fix CodeQL alerts: [brief summary]"`
```pwsh
git commit -m "[COMMIT_MESSAGE]"
@@ -317,4 +494,4 @@ git push origin [BRANCHNAME]
If the push is rejected for any reason, stop and ask the user.
-**CHECKPOINT**: "β Step 9 completed. Changes committed and pushed. Workflow complete."
+**CHECKPOINT**: "β Step 11 completed. Changes committed and pushed. Workflow complete."
diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml
index 0660f498d..bbba5ee33 100644
--- a/.github/workflows/cd.yml
+++ b/.github/workflows/cd.yml
@@ -595,6 +595,11 @@ jobs:
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
enable-AzPSSession: true
+ - name: Install Az.PostgreSql module
+ if: steps.changes.outputs.infra-changed == 'true'
+ shell: pwsh
+ run: Install-Module Az.PostgreSql -Force -Scope CurrentUser -ErrorAction Stop
+
- name: Deploy infrastructure (Phase 1)
if: steps.changes.outputs.infra-changed == 'true'
shell: pwsh
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 16e6ecf8a..b864a8923 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -2,7 +2,6 @@
true
-
@@ -11,6 +10,7 @@
+
@@ -43,4 +43,4 @@
-
+
\ No newline at end of file
diff --git a/docs/wildcard-certificates.md b/docs/wildcard-certificates.md
index 5fc667065..9a1de9684 100644
--- a/docs/wildcard-certificates.md
+++ b/docs/wildcard-certificates.md
@@ -37,11 +37,11 @@ These are permanent β they never need updating.
### One-time GoDaddy NS delegation
-After deploying the shared infrastructure (which creates the `acme.hub.ms` zone), add NS records for `acme` in the `hub.ms` zone at GoDaddy pointing to the Azure DNS nameservers:
+After deploying the infrastructure (which creates the `acme.hub.ms` zone), add NS records for `acme` in the `hub.ms` zone at GoDaddy pointing to the Azure DNS nameservers:
```bash
-# Get the nameservers after deploying shared infra
-az network dns zone show --resource-group rg-techhub-shared --name acme.hub.ms --query nameServers -o tsv
+# Get the nameservers after deploying infra
+az network dns zone show --resource-group rg-techhub-prod --name acme.hub.ms --query nameServers -o tsv
```
Add each returned nameserver as an NS record for `acme` in GoDaddy's `hub.ms` zone.
@@ -57,8 +57,8 @@ pip install certbot certbot-dns-azure
The ACME DNS zone is deployed as part of shared infrastructure:
- **Bicep module**: `infra/modules/acmeDnsZone.bicep`
-- **Deployed by**: `infra/shared.bicep` β `acmeDnsZone` module
-- **Resource**: `Microsoft.Network/dnsZones` β `acme.hub.ms` in `rg-techhub-shared`
+- **Deployed by**: `infra/infrastructure.bicep` β `acmeDnsZone` module
+- **Resource**: `Microsoft.Network/dnsZones` β `acme.hub.ms` in `rg-techhub-prod`
Deploy with:
diff --git a/infra/infrastructure.bicep b/infra/infrastructure.bicep
index 6e74e00d4..c9c4876ef 100644
--- a/infra/infrastructure.bicep
+++ b/infra/infrastructure.bicep
@@ -80,6 +80,12 @@ param emailServiceName string = 'eml-techhub-prod'
@description('Communication Service name')
param communicationServiceName string = 'acs-techhub-prod'
+@description('Custom email sending domain linked to ACS (e.g. mail.hub.ms). Must be verified in ACS after deploy.')
+param customEmailDomain string = 'mail.hub.ms'
+
+@description('Set to true only after the custom domain DNS records have been added and verified in ACS. Redeploy after verification.')
+param linkEmailDomain bool = false
+
@description('Common tags applied to all resources managed by this template')
param commonTags object = {
owner: 'techhub-maintainer'
@@ -305,6 +311,8 @@ module communication './modules/communication.bicep' = {
name: 'communication-${deploymentSuffix}'
params: {
emailServiceName: emailServiceName
+ customEmailDomain: customEmailDomain
+ linkEmailDomain: linkEmailDomain
communicationServiceName: communicationServiceName
tags: prodTags
}
diff --git a/infra/modules/communication.bicep b/infra/modules/communication.bicep
index b414722d0..00dd0a813 100644
--- a/infra/modules/communication.bicep
+++ b/infra/modules/communication.bicep
@@ -1,6 +1,8 @@
param emailServiceName string
-param managedDomainName string = 'AzureManagedDomain'
+param customEmailDomain string
param communicationServiceName string
+@description('Set to true only after the custom domain DNS records have been added and verified in ACS.')
+param linkEmailDomain bool = false
param tags object = {}
resource emailService 'Microsoft.Communication/emailServices@2026-03-18' = {
@@ -14,26 +16,33 @@ resource emailService 'Microsoft.Communication/emailServices@2026-03-18' = {
resource domain 'Microsoft.Communication/emailServices/domains@2026-03-18' = {
parent: emailService
- name: managedDomainName
+ name: customEmailDomain
location: 'global'
properties: {
- domainManagement: 'AzureManaged'
+ domainManagement: 'CustomerManaged'
userEngagementTracking: 'Disabled'
}
}
+resource newsletterSenderUsername 'Microsoft.Communication/emailServices/domains/senderUsernames@2026-03-18' = {
+ parent: domain
+ name: 'newsletter'
+ properties: {
+ username: 'newsletter'
+ displayName: 'TechHub Newsletter'
+ }
+}
+
resource communicationService 'Microsoft.Communication/communicationServices@2026-03-18' = {
name: communicationServiceName
location: 'global'
tags: tags
properties: {
dataLocation: 'Europe'
- linkedDomains: [
- domain.id
- ]
+ linkedDomains: linkEmailDomain ? [domain.id] : []
}
}
output communicationServiceEndpoint string = 'https://${communicationService.name}.communication.azure.com/'
output communicationServiceId string = communicationService.id
-output senderAddress string = 'DoNotReply@${domain.properties.mailFromSenderDomain}'
+output senderAddress string = 'newsletter@${domain.properties.mailFromSenderDomain}'
diff --git a/infra/parameters/prod-infrastructure.bicepparam b/infra/parameters/prod-infrastructure.bicepparam
index 1125b91f6..797bdbfdc 100644
--- a/infra/parameters/prod-infrastructure.bicepparam
+++ b/infra/parameters/prod-infrastructure.bicepparam
@@ -25,3 +25,4 @@ param adminIpAddresses = readEnvironmentVariable('ADMIN_IP_ADDRESSES')
param alertEmailAddress = 'reinier.vanmaanen@xebia.com'
param monthlyBudgetAmount = 250
param budgetStartDate = '2026-04-01'
+param linkEmailDomain = true
diff --git a/scripts/Deploy-Infrastructure.ps1 b/scripts/Deploy-Infrastructure.ps1
index 110f55f48..c380c7d4a 100644
--- a/scripts/Deploy-Infrastructure.ps1
+++ b/scripts/Deploy-Infrastructure.ps1
@@ -37,7 +37,10 @@ param(
[string]$Mode = 'whatif',
[Parameter(Mandatory = $false)]
- [string]$Location = 'swedencentral'
+ [string]$Location = 'swedencentral',
+
+ [Parameter(Mandatory = $false)]
+ [switch]$LinkEmailDomain
)
$ErrorActionPreference = "Stop"
@@ -222,18 +225,33 @@ if ($Mode -eq 'deploy') {
$savedVerbose = $VerbosePreference
$VerbosePreference = 'Continue'
try {
- New-AzDeployment `
- -Name $deploymentName `
- -Location $Location `
- -TemplateFile $infraTemplateFile `
- -TemplateParameterFile $infraParamsFile `
- -SkipTemplateParameterPrompt `
- -OutVariable infraResult | Out-Null
+ $effectiveParamsFile = $infraParamsFile
+ $tempParamsFile = $null
+ if ($LinkEmailDomain) {
+ Write-Warn "LinkEmailDomain is set β domain will be linked to ACS. Only use this after DNS verification."
+ # Write temp file to the same directory so the 'using' relative path remains valid
+ $tempParamsFile = Join-Path (Split-Path $infraParamsFile) "prod-infrastructure-linkdomain.bicepparam"
+ (Get-Content $infraParamsFile -Raw) `
+ -replace 'param linkEmailDomain = (?:false|true)', 'param linkEmailDomain = true' |
+ Set-Content $tempParamsFile
+ $effectiveParamsFile = $tempParamsFile
+ }
+ $deployParams = @{
+ Name = $deploymentName
+ Location = $Location
+ TemplateFile = $infraTemplateFile
+ TemplateParameterFile = $effectiveParamsFile
+ SkipTemplateParameterPrompt = $true
+ }
+ New-AzDeployment @deployParams -OutVariable infraResult | Out-Null
} catch {
Write-Fail "Infrastructure deployment failed: $_"
exit 1
} finally {
$VerbosePreference = $savedVerbose
+ if ($tempParamsFile -and (Test-Path $tempParamsFile)) {
+ Remove-Item $tempParamsFile -Force
+ }
}
Write-Ok "Base infrastructure deployed successfully"
diff --git a/scripts/Renew-WildcardCertificates.ps1 b/scripts/Renew-WildcardCertificates.ps1
index c7d2f1efe..814f5d59c 100644
--- a/scripts/Renew-WildcardCertificates.ps1
+++ b/scripts/Renew-WildcardCertificates.ps1
@@ -12,8 +12,8 @@
Prerequisites:
- certbot and certbot-dns-azure installed (pip install certbot certbot-dns-azure)
- - Azure CLI authenticated with access to rg-techhub-shared
- - ACME DNS zone deployed (acme.hub.ms in rg-techhub-shared)
+ - Azure CLI authenticated with access to rg-techhub-prod
+ - ACME DNS zone deployed (acme.hub.ms in rg-techhub-prod)
- GoDaddy CNAME records configured (see docs/wildcard-certificates.md)
.PARAMETER KeyVaultName
@@ -51,7 +51,7 @@ param(
[string]$KeyVaultName = 'kv-techhub-prod',
[Parameter(Mandatory = $false)]
- [string]$ResourceGroup = 'rg-techhub-shared',
+ [string]$ResourceGroup = 'rg-techhub-prod',
[Parameter(Mandatory = $false)]
[string]$AcmeDnsZone = 'acme.hub.ms',
@@ -147,7 +147,7 @@ Write-Ok "certbot-dns-azure plugin found"
az network dns zone show --resource-group $ResourceGroup --name $AcmeDnsZone -o json 2>&1 | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Fail "DNS zone '$AcmeDnsZone' not found in resource group '$ResourceGroup'."
- Write-Detail "Deploy shared infrastructure first: ./scripts/Deploy-Infrastructure.ps1 -Environment shared -Mode deploy"
+ Write-Detail "Deploy infrastructure first: ./scripts/Deploy-Infrastructure.ps1 -Mode deploy"
exit 1
}
Write-Ok "ACME DNS zone '$AcmeDnsZone' exists"
diff --git a/src/AGENTS.md b/src/AGENTS.md
index 014b0c1b1..b5b01d5ff 100644
--- a/src/AGENTS.md
+++ b/src/AGENTS.md
@@ -45,6 +45,32 @@
- **End users get friendly error messages** β But logs and Application Insights must contain full exception details, parameters, and context
- **Log then rethrow or let it crash** β Prefer logging with context and rethrowing over catching and returning a default value
+### Accepted Broad-Catch Pattern
+
+Catching all non-cancellation exceptions (`when (ex is not OperationCanceledException)`) is **accepted and preferred** in two scenarios:
+
+1. **Background service loops** β To prevent the entire service (and thus the application) from crashing on one unexpected error. The service logs, then continues processing on the next tick.
+
+ ```csharp
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ _logger.LogError(ex, "Unhandled exception in β¦ β service will continue");
+ }
+ ```
+
+2. **Per-item processing loops** β To skip a failing item and continue with the next (e.g., content processing, newsletter subscriber sending). Must be suppressed with `#pragma warning disable CA1031` and a comment explaining the intent.
+
+ ```csharp
+ #pragma warning disable CA1031 // Best-effort: continue with other items if one fails
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ _logger.LogWarning(ex, "Failed to process {Item} β skipping", item);
+ }
+ #pragma warning restore CA1031
+ ```
+
+Do **not** add `OutOfMemoryException` or `StackOverflowException` exclusions β the CLR already terminates the process for those regardless of catch blocks.
+
## Project Structure
```text
diff --git a/src/TechHub.Api/AGENTS.md b/src/TechHub.Api/AGENTS.md
index df7e5fdfe..faad254bf 100644
--- a/src/TechHub.Api/AGENTS.md
+++ b/src/TechHub.Api/AGENTS.md
@@ -6,7 +6,18 @@
REST API backend using ASP.NET Core Minimal APIs. Exposes endpoints for sections, content, filtering, RSS feeds, and structured data.
-**Testing**: See [tests/TechHub.Api.Tests/AGENTS.md](../../tests/TechHub.Api.Tests/AGENTS.md) for integration testing patterns.
+## Background Services
+
+Background services inherit from `BackgroundService` and are registered in `Program.cs` via `AddHostedService()`. The outer execution loop MUST catch all non-cancellation exceptions to prevent the entire application from crashing:
+
+```csharp
+catch (Exception ex) when (ex is not OperationCanceledException)
+{
+ _logger.LogError(ex, "Unhandled exception in MyBackgroundService β service will continue");
+}
+```
+
+See [src/AGENTS.md](../AGENTS.md#accepted-broad-catch-pattern) for the accepted broad-catch pattern and rationale.
## Project Structure
diff --git a/src/TechHub.Api/Program.cs b/src/TechHub.Api/Program.cs
index 81d5a2ec0..3426669fb 100644
--- a/src/TechHub.Api/Program.cs
+++ b/src/TechHub.Api/Program.cs
@@ -261,6 +261,7 @@
builder.Configuration.GetSection(NewsletterOptions.SectionName));
builder.Services.AddScoped();
builder.Services.AddScoped();
+builder.Services.AddSingleton();
builder.Services.AddScoped();
builder.Services.AddAcsEmailClient();
builder.Services.AddScoped();
diff --git a/src/TechHub.Api/Services/ContentFixerBackgroundService.cs b/src/TechHub.Api/Services/ContentFixerBackgroundService.cs
index b026acc8f..091bb006a 100644
--- a/src/TechHub.Api/Services/ContentFixerBackgroundService.cs
+++ b/src/TechHub.Api/Services/ContentFixerBackgroundService.cs
@@ -124,7 +124,7 @@ await jobRepo.AbortJobAsync(jobId,
transcriptsSucceeded: 0, transcriptsFailed: 0,
logOutput: "Content cleanup aborted by admin.", ct: CancellationToken.None);
}
- catch (Exception ex) when (ex is not OperationCanceledException and not OutOfMemoryException and not StackOverflowException)
+ catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Content fixer run failed (job {JobId})", jobId);
await jobRepo.FailAsync(jobId,
@@ -137,7 +137,7 @@ await jobRepo.FailAsync(jobId,
{
// Shutting down β expected
}
- catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException)
+ catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Unexpected exception in ContentFixerBackgroundService");
}
diff --git a/src/TechHub.Api/Services/ContentProcessingBackgroundService.cs b/src/TechHub.Api/Services/ContentProcessingBackgroundService.cs
index a89d6c308..235ae2253 100644
--- a/src/TechHub.Api/Services/ContentProcessingBackgroundService.cs
+++ b/src/TechHub.Api/Services/ContentProcessingBackgroundService.cs
@@ -172,7 +172,7 @@ private async Task RunOnceAsync(string triggerType, CancellationToken ct)
{
// Shutting down β expected
}
- catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException)
+ catch (Exception ex) when (ex is not OperationCanceledException)
{
// Errors are already recorded by ContentProcessingService; log defensively here
_logger.LogError(ex, "Unexpected exception in ContentProcessingBackgroundService");
diff --git a/src/TechHub.Api/Services/NewsletterBackgroundService.cs b/src/TechHub.Api/Services/NewsletterBackgroundService.cs
index cb3d049d3..0b122a130 100644
--- a/src/TechHub.Api/Services/NewsletterBackgroundService.cs
+++ b/src/TechHub.Api/Services/NewsletterBackgroundService.cs
@@ -76,7 +76,15 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
if (completed == manualTask)
{
_manualTrigger = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
- await RunManualAsync(stoppingToken);
+ try
+ {
+ await RunManualAsync(stoppingToken);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ _logger.LogError(ex, "Unhandled exception in newsletter manual run β service will continue");
+ }
+
continue;
}
@@ -87,8 +95,15 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
continue;
}
- await SendLatestRoundupsAsync(stoppingToken);
- await SendScheduledDailyEmailsAsync(stoppingToken);
+ try
+ {
+ await SendLatestRoundupsAsync(stoppingToken);
+ await SendScheduledDailyEmailsAsync(stoppingToken);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ _logger.LogError(ex, "Unhandled exception in newsletter periodic run β service will continue");
+ }
}
}
diff --git a/src/TechHub.Api/Services/RoundupGeneratorBackgroundService.cs b/src/TechHub.Api/Services/RoundupGeneratorBackgroundService.cs
index b6d857b54..5734ae44d 100644
--- a/src/TechHub.Api/Services/RoundupGeneratorBackgroundService.cs
+++ b/src/TechHub.Api/Services/RoundupGeneratorBackgroundService.cs
@@ -293,7 +293,7 @@ await jobRepo.AbortJobAsync(jobId, feedsProcessed: 0, itemsAdded: 0, itemsSkippe
{
// Shutting down β expected
}
- catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException)
+ catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Unexpected exception in RoundupGeneratorBackgroundService (job {JobId})", jobId);
await jobRepo.AppendLogAsync(jobId, $"Roundup generation failed: {ex.Message}\n{ex.StackTrace}", CancellationToken.None);
diff --git a/src/TechHub.Api/appsettings.json b/src/TechHub.Api/appsettings.json
index 59f1691d2..4a5b989f1 100644
--- a/src/TechHub.Api/appsettings.json
+++ b/src/TechHub.Api/appsettings.json
@@ -429,7 +429,7 @@
"AdminReportEmailAddress": "whistler112@gmail.com",
"DailyDigestHourLocal": 9,
"DailyDigestTimeZoneId": "Europe/Brussels",
- "SendDelayMs": 200
+ "SendDelayMs": 2500
},
"AiCategorization": {
"Endpoint": "",
diff --git a/src/TechHub.Core/Interfaces/INewsletterSubscriberRepository.cs b/src/TechHub.Core/Interfaces/INewsletterSubscriberRepository.cs
index 1916c6cd8..9fac6762a 100644
--- a/src/TechHub.Core/Interfaces/INewsletterSubscriberRepository.cs
+++ b/src/TechHub.Core/Interfaces/INewsletterSubscriberRepository.cs
@@ -52,6 +52,7 @@ Task LogSendAsync(
string sendKind,
string targetKey,
int recipientCount,
+ int failedCount,
string status,
string? errorMessage,
CancellationToken ct = default);
diff --git a/src/TechHub.Core/Models/Admin/NewsletterDailyReportStats.cs b/src/TechHub.Core/Models/Admin/NewsletterDailyReportStats.cs
index 443ef0ce7..aef4dbdb3 100644
--- a/src/TechHub.Core/Models/Admin/NewsletterDailyReportStats.cs
+++ b/src/TechHub.Core/Models/Admin/NewsletterDailyReportStats.cs
@@ -8,4 +8,5 @@ public sealed class NewsletterDailyReportStats
public int NewSubscribersLast24Hours { get; init; }
public int ActiveSubscribers { get; init; }
public int UnconfirmedSubscribers { get; init; }
+ public int FailedNewsletterSendsLast24Hours { get; init; }
}
diff --git a/src/TechHub.Core/Models/Admin/NewsletterSendLogEntry.cs b/src/TechHub.Core/Models/Admin/NewsletterSendLogEntry.cs
index 66b29ce78..cb931cf5b 100644
--- a/src/TechHub.Core/Models/Admin/NewsletterSendLogEntry.cs
+++ b/src/TechHub.Core/Models/Admin/NewsletterSendLogEntry.cs
@@ -7,6 +7,7 @@ public sealed class NewsletterSendLogEntry
public string TargetKey { get; init; } = string.Empty;
public DateTimeOffset SentAt { get; init; }
public int RecipientCount { get; init; }
+ public int FailedCount { get; init; }
public string Status { get; init; } = string.Empty;
public string? ErrorMessage { get; init; }
}
diff --git a/src/TechHub.Infrastructure/AGENTS.md b/src/TechHub.Infrastructure/AGENTS.md
index 42438a8f4..cfdf6e01a 100644
--- a/src/TechHub.Infrastructure/AGENTS.md
+++ b/src/TechHub.Infrastructure/AGENTS.md
@@ -55,6 +55,22 @@ See [Data/Migrations/postgres/](Data/Migrations/postgres/) for complete schema.
| Scoped | `IDbConnection` (per-request) |
| Transient | All other services |
+## Per-Item Processing Loops
+
+Processing pipelines (content ingestion, newsletter sending, roundup generation) iterate over collections and MUST NOT abort the entire run when one item fails. Use the broad-catch pattern with `#pragma warning disable CA1031`:
+
+```csharp
+#pragma warning disable CA1031 // Best-effort: continue with other items if one fails
+catch (Exception ex) when (ex is not OperationCanceledException)
+{
+ errorCount++;
+ _logger.LogWarning(ex, "Failed to process {Item} β skipping", item);
+}
+#pragma warning restore CA1031
+```
+
+See [src/AGENTS.md](../AGENTS.md#accepted-broad-catch-pattern) for the full rationale.
+
## Tag Cloud
`TagCloudService` queries tags with section/collection title exclusion (repository filters these BEFORE counting). Quantile sizing: top 25% Large, middle 50% Medium, bottom 25% Small. Size group normalization: 1 group β all Medium, 2 β Medium + Small.
diff --git a/src/TechHub.Infrastructure/Data/DapperExtensions.cs b/src/TechHub.Infrastructure/Data/DapperExtensions.cs
index e20849e7e..bf8a7b055 100644
--- a/src/TechHub.Infrastructure/Data/DapperExtensions.cs
+++ b/src/TechHub.Infrastructure/Data/DapperExtensions.cs
@@ -151,7 +151,7 @@ private static void LogExplainPlan(IDbConnection connection, string sql, object?
logger.LogWarning("{ExplainPlan}", sb.ToString().TrimEnd());
}
- catch (Exception ex) when (ex is not OutOfMemoryException)
+ catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogWarning(ex, "Failed to retrieve EXPLAIN plan for slow query");
}
diff --git a/src/TechHub.Infrastructure/Data/Migrations/postgres/046_newsletter_send_log_failed_count.sql b/src/TechHub.Infrastructure/Data/Migrations/postgres/046_newsletter_send_log_failed_count.sql
new file mode 100644
index 000000000..371c1a1ce
--- /dev/null
+++ b/src/TechHub.Infrastructure/Data/Migrations/postgres/046_newsletter_send_log_failed_count.sql
@@ -0,0 +1,2 @@
+ALTER TABLE newsletter_send_log
+ ADD COLUMN IF NOT EXISTS failed_count INT NOT NULL DEFAULT 0;
diff --git a/src/TechHub.Infrastructure/Data/Resources/newsletter-account-action-content.html b/src/TechHub.Infrastructure/Data/Resources/newsletter-account-action-content.html
new file mode 100644
index 000000000..dc724e890
--- /dev/null
+++ b/src/TechHub.Infrastructure/Data/Resources/newsletter-account-action-content.html
@@ -0,0 +1,5 @@
+
Or copy this link into your browser: {WebUtility.HtmlEncode(confirmUrl)}
-
- """;
+ var html = BuildAccountActionHtml(
+ title: "Confirm your TechHub newsletter subscription",
+ message: "Click the button below to confirm your subscription. If you did not sign up, you can safely ignore this email.",
+ actionLabel: "Confirm subscription",
+ actionUrl: confirmUrl);
var text = $"""
Confirm your TechHub newsletter subscription
@@ -682,66 +667,43 @@ FROM ranked
return result;
}
- private string BuildSectionLinksHtml(RoundupRow roundup)
+ private IEnumerable