Skip to content

Commit 589baa1

Browse files
authored
Add exact title match search, newsletter dark theme, and background job safe defaults (#456)
* Add exact title match search and newsletter fixes Summary: Adds exact title match search used by newsletter links, restyle newsletter emails to the site's dark theme, and make all background job enabled flags default to false with only production explicitly enabling them. Implementation: • Add ExactTitleMatch to SearchRequest with cache key support • Add exact ILIKE query path in ContentRepository • Expose exact=true query param on content search API endpoint • Propagate exact param through ITechHubApiClient and TechHubApiClient • Add ExactMatch mode to ContentItemsGrid and SectionCollection page • Restyle newsletter email templates (daily + weekly) to dark purple theme • Default ContentProcessorOptions.Enabled, RoundupGeneratorOptions.Enabled, and NewsletterOptions.ScheduledSendEnabled to false (C# bool default) • Enable all three background jobs explicitly in appsettings.Production.json • Update bicep backgroundJobEnvVars comment for new config layout • Delete obsolete appsettings.Staging.json from both Api and Web projects • Update Web component tests for new ContentItemsGrid/Section signatures * Clarify daily email behavior on subscribe page * Address PR review comments: fix exact-match bugs, staging config, and add tests - Fix ILIKE substring match: use bare query (no %wildcards%) in exact mode - Fix exact=false treated as exact: use string.Equals(..., 'true') instead of IsNullOrWhiteSpace - Fix tags not cleared in exact mode: pass Array.Empty<string>() when exactMode - Add exactMode guard: exact=true without query behaves as normal request - Replace bicep enableBackgroundJobs env var with appsettings.Staging.json for PR preview environments - Add 5 integration tests for exact title matching behaviour - Fix pre-existing test bug: TriggerImmediateRun_WhenEnabled_ExecutesManualRun missing Enabled=true * Add exact phrase match checkbox to sidebar; fix exact mode to use phrase containment - SidebarSearch: add 'Exact title match' checkbox below search input (visible only when query is non-empty) - SidebarSearch: new ExactMatch parameter and OnExactMatchChanged callback - SidebarSearch.css: checkbox label styled with design tokens - SectionCollection: wire checkbox to isExactMatch state and HandleExactMatchChanged - ContentRepository: fix exact mode ILIKE to use %query% (phrase containment) instead of bare equality - 'vscode update' now matches titles containing 'vscode update', not just titles equal to it - newsletter links (exact=true + full title) continue to work correctly - ContentEndpointsTests: update tests to match phrase-containment semantics; rename DoesNotMatchSubstrings to MatchesByPhrase - SidebarSearchTests: add 4 bUnit tests for checkbox visibility, checked state, and change event - docs/filtering.md: document exact phrase match mode, URL parameter, and checkbox interaction * Fix ContentItemsGrid double-load when exact=true on first render Initialize previousExactMatch from ExactMatch in OnInitializedAsync alongside all other previous* fields. Without this, navigating to a URL with ?exact=true caused OnParametersSetAsync to immediately detect a mismatch (false vs true) and fire a redundant LoadInitialBatch.
1 parent 9dd3eaa commit 589baa1

31 files changed

Lines changed: 505 additions & 157 deletions

docs/filtering.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ This enables auto-complete-style search behavior where users don't need to type
3434
- Clear button (X icon) to remove the search query
3535
- Keyboard support (Escape key clears the search)
3636
- URL parameter synchronization (`?search=query`)
37+
- **Exact phrase match checkbox** — visible when a query is entered; activates exact phrase mode (see below)
3738

3839
**Backend**: Full-text search is implemented at the database level:
3940

@@ -62,6 +63,7 @@ Search state is preserved in the URL for sharing and bookmarking:
6263
?search=copilot # Search only
6364
?search=copilot&tags=ai # Search + tag filter
6465
?search=copilot&tags=ai&from=2024-01-01 # Search + tag + date filter
66+
?search=vscode+update&exact=true # Exact phrase match
6567
```
6668

6769
**Implementation Details**:
@@ -71,6 +73,32 @@ Search state is preserved in the URL for sharing and bookmarking:
7173
- Combined with `tags`, `from`, `to` parameters
7274
- Supports browser back/forward navigation
7375

76+
### Exact Phrase Match
77+
78+
When `?exact=true` is present together with a non-empty `?search=` value, the full-text search engine is bypassed and replaced with a case-insensitive **phrase containment** check on the title:
79+
80+
```sql
81+
WHERE title ILIKE '%<query>%'
82+
```
83+
84+
- **"vscode update"** matches "VSCode Update 1.90", "The VSCode Update you missed", etc.
85+
- **"vscode update"** does **not** match "VSCode tips" — "update" is missing from that title.
86+
- Date, tag, and content-type filters are **ignored** in exact phrase mode.
87+
- Results are sorted by date (newest first), not by relevance rank.
88+
89+
**Use cases**:
90+
91+
- **Newsletter links** — each daily-update item links back to the website with `?search={title}&exact=true` so the linked page always shows only that item regardless of the current date range.
92+
- **Sidebar checkbox** — the "Exact title match" checkbox under the search box activates this mode interactively. It appears only when the search field has text. Typing a new query automatically deactivates exact mode.
93+
94+
If `?exact=true` is present but `?search=` is empty or absent, it is ignored and normal filtering applies (including the default date range).
95+
96+
**API parameter**: `exact` (boolean, passed as `true`/`false` string). Example:
97+
98+
```bash
99+
curl -k "https://localhost:5001/api/sections/ai/collections/all/items?q=vscode+update&exact=true"
100+
```
101+
74102
## Tag Storage and Expansion
75103

76104
### How Tags Are Stored

infra/modules/api.bicep

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,6 @@ param newsletterUnsubscribeSecretName string = ''
5050
@description('ASPNETCORE_ENVIRONMENT value. Use "Staging" for PR preview environments.')
5151
param aspNetCoreEnvironment string = 'Production'
5252

53-
@description('When false, disables scheduled background jobs (ContentProcessor, RoundupGenerator). Set to false for PR preview environments.')
54-
param enableBackgroundJobs bool = true
55-
5653
@description('Minimum replica count. Use 0 to enable scale-to-zero for PR preview environments.')
5754
param minReplicas int = 1
5855

@@ -73,16 +70,6 @@ var corsEnvVars = [for (fqdn, i) in webFqdns: {
7370
name: 'Cors__AllowedOrigins__${i}'
7471
value: 'https://${fqdn}'
7572
}]
76-
var backgroundJobEnvVars = enableBackgroundJobs ? [] : [
77-
{
78-
name: 'ContentProcessor__Enabled'
79-
value: 'false'
80-
}
81-
{
82-
name: 'RoundupGenerator__Enabled'
83-
value: 'false'
84-
}
85-
]
8673
var newsletterSecretEnvVars = empty(newsletterUnsubscribeSecretName)
8774
? []
8875
: [
@@ -253,7 +240,7 @@ resource api 'Microsoft.App/containerApps@2025-07-01' = {
253240
cpu: json('0.25')
254241
memory: '0.5Gi'
255242
}
256-
env: concat(staticEnvVars, newsletterAcsEnvVars, newsletterSenderEnvVars, newsletterSecretEnvVars, corsEnvVars, backgroundJobEnvVars)
243+
env: concat(staticEnvVars, newsletterAcsEnvVars, newsletterSenderEnvVars, newsletterSecretEnvVars, corsEnvVars)
257244
probes: [
258245
{
259246
type: 'startup'

infra/pr-applications.bicep

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,6 @@ module apiApp './modules/api.bicep' = {
9090
aiCategorizationEndpoint: '' // AI categorization disabled (background jobs are off)
9191
aiCategorizationDeploymentName: ''
9292
aspNetCoreEnvironment: 'Staging'
93-
enableBackgroundJobs: false // prevent PR from triggering content sync / roundup jobs
9493
minReplicas: 0 // scale to zero when idle to minimize cost
9594
maxReplicas: 1
9695
}

src/TechHub.Api/Endpoints/ContentEndpoints.cs

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,7 @@ private static async Task<Results<Ok<CollectionItemsResponse>, NotFound, BadRequ
264264
[FromQuery] string? from = null,
265265
[FromQuery] string? to = null,
266266
[FromQuery] string? types = null,
267+
[FromQuery] bool exact = false,
267268
CancellationToken cancellationToken = default)
268269
{
269270
sectionName = sectionName.Sanitize();
@@ -360,9 +361,12 @@ private static async Task<Results<Ok<CollectionItemsResponse>, NotFound, BadRequ
360361
(dateFrom, dateTo) = (dateTo, dateFrom);
361362
}
362363

363-
// Build collections array - when "all" and types specified, filter to specific collection types
364+
// Build collections array - when "all" and types specified, filter to specific collection types.
365+
// When exact=true, types are ignored (exact match covers all collections).
366+
// Exact mode requires a non-empty query; without one, behave as a normal request.
367+
var exactMode = exact && !string.IsNullOrWhiteSpace(q);
364368
string[] collectionsArray;
365-
if (isAllCollection && !string.IsNullOrWhiteSpace(types))
369+
if (!exactMode && isAllCollection && !string.IsNullOrWhiteSpace(types))
366370
{
367371
// Validate types against section's actual non-custom collections
368372
var validCollectionNames = section.Collections
@@ -384,16 +388,18 @@ private static async Task<Results<Ok<CollectionItemsResponse>, NotFound, BadRequ
384388
collectionsArray = new[] { collectionName };
385389
}
386390

387-
// Build search request - repository will handle "all" as no filter
391+
// Build search request - repository will handle "all" as no filter.
392+
// When exact=true, skip date and tag filters (exact match is purely title-based).
388393
var request = new SearchRequest(
389394
take: limit,
390395
skip: offset,
391396
query: q,
392397
sections: new[] { section.Name },
393398
collections: collectionsArray,
394-
tags: parsedTags ?? Array.Empty<string>(),
395-
dateFrom: dateFrom,
396-
dateTo: dateTo
399+
tags: exactMode ? Array.Empty<string>() : (parsedTags ?? Array.Empty<string>()),
400+
dateFrom: exactMode ? null : dateFrom,
401+
dateTo: exactMode ? null : dateTo,
402+
exactTitleMatch: exactMode
397403
);
398404

399405
var content = await contentRepository.SearchAsync(request, cancellationToken);

src/TechHub.Api/appsettings.Development.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
]
1414
},
1515
"Newsletter": {
16-
"ScheduledSendEnabled": false,
1716
"ConnectionString": "",
1817
"SenderAddress": "",
1918
"WebsiteBaseUrl": "https://localhost:5003"

src/TechHub.Api/appsettings.Production.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,14 @@
1212
"https://tech.hub.ms",
1313
"https://tech.xebia.ms"
1414
]
15+
},
16+
"ContentProcessor": {
17+
"Enabled": true
18+
},
19+
"RoundupGenerator": {
20+
"Enabled": true
21+
},
22+
"Newsletter": {
23+
"ScheduledSendEnabled": true
1524
}
1625
}
Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,11 @@
11
{
2-
"AppSettings": {
3-
"BaseUrl": "https://staging-tech.hub.ms"
2+
"ContentProcessor": {
3+
"Enabled": false
44
},
5-
"Database": {
6-
"Provider": "PostgreSQL",
7-
"ConnectionString": ""
5+
"RoundupGenerator": {
6+
"Enabled": false
87
},
9-
"Cors": {
10-
"AllowedOrigins": [
11-
"https://staging-tech.hub.ms"
12-
]
8+
"Newsletter": {
9+
"ScheduledSendEnabled": false
1310
}
1411
}

src/TechHub.Api/appsettings.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -421,7 +421,6 @@
421421
"MaxRetries": 3
422422
},
423423
"Newsletter": {
424-
"ScheduledSendEnabled": false,
425424
"ConnectionString": "",
426425
"SenderAddress": "",
427426
"WebsiteBaseUrl": "",

src/TechHub.Core/Configuration/ContentProcessorOptions.cs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,11 @@ public class ContentProcessorOptions
99
public const string SectionName = "ContentProcessor";
1010

1111
/// <summary>
12-
/// Whether scheduled content processing is enabled. Defaults to true.
13-
/// Set <c>ContentProcessor__Enabled=false</c> via environment variable to disable scheduling
14-
/// in environments where automatic processing should not run (e.g. PR preview environments).
12+
/// Whether scheduled content processing is enabled. Defaults to false.
13+
/// Set to <c>true</c> only in production via <c>appsettings.Production.json</c>.
1514
/// Manual admin-triggered runs are unaffected by this setting.
1615
/// </summary>
17-
public bool Enabled { get; init; } = true;
16+
public bool Enabled { get; init; }
1817

1918
/// <summary>How often to run the content processing pipeline (in minutes).</summary>
2019
public int IntervalMinutes { get; init; } = 60;

src/TechHub.Core/Configuration/NewsletterOptions.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ public sealed class NewsletterOptions
44
{
55
public const string SectionName = "Newsletter";
66

7+
/// <summary>
8+
/// Whether scheduled newsletter sending is enabled. Defaults to false.
9+
/// Set to <c>true</c> only in production via <c>appsettings.Production.json</c>.
10+
/// </summary>
711
public bool ScheduledSendEnabled { get; init; }
812
public string Endpoint { get; init; } = string.Empty;
913
public string SenderAddress { get; init; } = string.Empty;

0 commit comments

Comments
 (0)