Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .squad/agents/aragorn/history.md
Original file line number Diff line number Diff line change
Expand Up @@ -1148,3 +1148,31 @@ value object into the command.
**EF Core MongoDB provider handles owned entities via `OwnsOne` — no BsonElement attributes needed.** The mapping is declared in `OnModelCreating` exactly like SQL EF Core. Primitive collection properties (e.g., `IReadOnlyList<string> Roles`) are supported on owned types.

**String-to-embedded-object is a breaking MongoDB schema change.** Even in dev, existing documents will cause deserialization failures. Drop/recreate in dev; migration script required for any environment with live data.
## 2026-05-11 — PR #297 Review + Merge: L1+L2 caching for UserManagement Auth0 API

**Requested by:** Boromir (Ralph work-check cycle Round 2)
**Issue:** #293 — Member roles page making N+1 Auth0 Management API calls on every page load
**PR:** #297 `squad/293-member-roles-caching` → `dev`

### Review Findings

**Files reviewed:**

- `UserManagementCacheKeys.cs` — const string keys `usermgmt:users` / `usermgmt:roles`. Clean, follows BlogPostCacheKeys pattern exactly.
- `IUserManagementCacheService.cs` — interface with `GetOrFetchUsersAsync`, `GetOrFetchRolesAsync`, `InvalidateUsersAsync`, `InvalidateRolesAsync`. `ValueTask<T>` return type correct (L1 hits complete synchronously without heap allocation). `CancellationToken.None` on Redis removal post-mutation documented in XML remarks.
- `UserManagementCacheService.cs` — L1 30s / L2 2min, JSON serialization with `JsonSerializerDefaults.Web`, corrupt-L2 catch block with fallthrough. Matches BlogPostCacheService implementation exactly.
- `CachingServiceExtensions.cs` — `AddUserManagementCaching()` registers as `Singleton`. Correct: both `IMemoryCache` and `IDistributedCache` are singletons; no captive-dependency violation.
- `Program.cs` — `AddUserManagementCaching()` called immediately after `AddBlogPostCaching()`. Clean placement.
- `UserManagementHandler.cs` — `GetOrFetchUsersAsync` and `GetOrFetchRolesAsync` wrap Auth0 API calls. `InvalidateUsersAsync` called on `AssignRole` and `RemoveRole`. ✅ `InvalidateRolesAsync` NOT called on assign/remove — correct, because available roles in Auth0 are static; assigning/removing a user's role doesn't change which roles exist.
- `UserManagementHandlerTests.cs` — `BuildPassThroughCache()` helper is well-designed: NSubstitute mock delegates `GetOrFetchUsersAsync`/`GetOrFetchRolesAsync` to the caller-supplied `Func<Task<...>>`, so all existing config-missing and HTTP-failure assertions still exercise the real fetch logic. All five static builder helpers threaded correctly.

**CI status at review:** All 17 checks green (7 test suites, CodeQL, Codecov, markdownlint, build).

**Verdict: ✅ APPROVED** — pattern conformance exact, cache invalidation semantically correct, DI registration clean.

### Outcome

- **GitHub approve** rejected (cannot approve own PR — established protocol).
- Squash-merged to `dev`: "feat(app): add L1+L2 caching to UserManagement Auth0 API calls (#297)"
- Branch `squad/293-member-roles-caching` deleted (local + remote).
- Closes #293.
23 changes: 23 additions & 0 deletions .squad/agents/legolas/history.md
Original file line number Diff line number Diff line change
Expand Up @@ -776,3 +776,26 @@ Then each variant only declares its colour-specific overrides. This is idiomatic
6. **`form-input`/`form-label` bump to `text-lg font-semibold`** — Unusually large/bold for form field text; worth a visual QA check.

**Verdict:** Approved with concerns (items 1, 2, 3 are the meaningful ones for follow-up).

### 2025-07 — Issue #296: Auto-fill Author on Create Post page

**What I implemented:**

- Replaced the manual `Author` text input in `Create.razor` with auto-population from `AuthenticationStateProvider`
- Injected `AuthenticationStateProvider` + `RoleClaimsHelper` (via `@using MyBlog.Web.Security`)
- `OnInitializedAsync` reads `sub`, `name`, `email`, and roles from claims; `_authorName` displayed as read-only above the form
- `HandleSubmit` builds `PostAuthor` from the private fields and passes it to `CreateBlogPostCommand`
- `PostFormModel` has no `Author` property; Title + Content only

**bUnit test fixes:**

- Created `TestAuthenticationStateProvider` (implements `AuthenticationStateProvider`) in `tests/Web.Tests.Bunit/Testing/` to satisfy the DI injection that `Create.razor` requires
- Registered it as a singleton in `RazorSmokeTests` constructor
- Updated `RenderWithUser` to call `_authProvider.SetUser(principal)` before rendering
- Updated Create tests: removed `FindAll("input")[1]` stale references (Author input no longer exists); adjusted `BeGreaterThanOrEqualTo(2)` → `(1)`

**Key patterns to remember:**

- When a Blazor component injects `AuthenticationStateProvider` directly (not just cascading state), bUnit tests need it registered as a DI service — the cascading `Task<AuthenticationState>` alone is not enough
- `RoleClaimsHelper.GetRoles(user)` handles all Auth0 role claim namespace variations automatically; always prefer it over manual `ClaimTypes.Role` filtering
- Auth0 `name` claim is "name" (not `ClaimTypes.Name`); fallback to `user.Identity?.Name` handles standard auth
48 changes: 48 additions & 0 deletions .squad/agents/sam/history.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,3 +178,51 @@ the field guards all three dev commands (Clear, Seed, Stats), not just Clear.
- ✅ Architecture.Tests: 15/15, Domain.Tests: 42/42, Integration.Tests: 12/12

### PR: #267

## 2026-05-xx — Issue #296: PostAuthor Value Object

### Task

Replace `string Author` on `BlogPost` with an immutable `PostAuthor` value object, per Aragorn's ADR (`aragorn-296-post-author-adr.md`).

### Changes Made

1. **`src/Domain/ValueObjects/PostAuthor.cs`** — New `sealed record PostAuthor(string Id, string Name, string Email, IReadOnlyList<string> Roles)` with `PostAuthor.Empty` helper
2. **`src/Domain/Entities/BlogPost.cs`** — `Author` property changed from `string` to `PostAuthor`; `Create()` guards: `ArgumentNullException.ThrowIfNull(author)` then `ArgumentException.ThrowIfNullOrWhiteSpace(author.Name)`
3. **`src/Web/Data/BlogDbContext.cs`** — Added `entity.OwnsOne(p => p.Author, ...)` with `HasElementName` for each field to control MongoDB sub-document field names
4. **`src/Web/Data/BlogPostDto.cs`** — Replaced `string Author` with `string AuthorId, string AuthorName, string AuthorEmail, IReadOnlyList<string> AuthorRoles` (positional record — all call sites updated)
5. **`src/Web/Data/BlogPostMappings.cs`** — Updated `ToDto()` to map flat author fields
6. **`src/Web/Features/BlogPosts/Create/CreateBlogPostCommand.cs`** — `PostAuthor Author` replaces `string Author`
7. **`src/Web/Features/BlogPosts/Create/CreateBlogPostCommandValidator.cs`** — `NotNull()` on Author + `NotEmpty().When(author not null)` on Author.Name
8. **`src/Web/Features/BlogPosts/Create/Create.razor`** — Temporary stub builds `PostAuthor` from `_model.Author` string field; Legolas must replace with `AuthenticationStateProvider` injection
9. **`src/Web/Features/BlogPosts/List/Index.razor`** — `@post.Author` → `@post.AuthorName`
10. **All test projects** — GlobalUsings, entity construction, DTO construction updated

### Build Validation

- ✅ Release build: 0 errors, 0 warnings
- ✅ Architecture.Tests: 16/16
- ✅ Domain.Tests: 42/42
- ✅ Web.Tests: 151/151
- ✅ Integration.Tests: 12/12 (Docker)
- ✅ All pre-push gates passed

### PR: #298

### Learnings

#### `PostAuthor.Empty` is a testing convenience — not valid for `BlogPost.Create()`

- `PostAuthor.Empty` has `Name = ""`. The `BlogPost.Create()` guard rejects empty Name.
- Tests that exercise handler behavior (not null/empty author validation) must use `new PostAuthor("", "Test Author", "", [])`, not `PostAuthor.Empty`.
- Only use `PostAuthor.Empty` in command/validator tests that specifically test the "empty author" failure path.

#### Razor files need explicit `@using` for value objects

- Razor components do not automatically inherit global usings from `GlobalUsings.cs` files in the same project for all scenarios.
- Add `@using MyBlog.Domain.ValueObjects` explicitly at the top of any `.razor` file that references `PostAuthor`.

#### Breaking schema changes need a migration note in the PR

- MongoDB `OwnsOne` with `HasElementName` changes the stored field name from a flat `"Author"` string to a sub-document `{"AuthorId":...,"AuthorName":...}`.
- Existing documents fail to deserialize; always document the migration strategy (drop/recreate in dev; script in prod) in the PR.
9 changes: 6 additions & 3 deletions src/Domain/Entities/BlogPost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,26 +7,29 @@
//Project Name : Domain
//=======================================================

using MyBlog.Domain.ValueObjects;

namespace MyBlog.Domain.Entities;

public sealed class BlogPost
{
public Guid Id { get; private set; }
public string Title { get; private set; } = string.Empty;
public string Content { get; private set; } = string.Empty;
public string Author { get; private set; } = string.Empty;
public PostAuthor Author { get; private set; } = PostAuthor.Empty;
public DateTime CreatedAt { get; private set; }
public DateTime? UpdatedAt { get; private set; }
public bool IsPublished { get; private set; }
public int Version { get; private set; }

private BlogPost() { }

public static BlogPost Create(string title, string content, string author)
public static BlogPost Create(string title, string content, PostAuthor author)
{
ArgumentException.ThrowIfNullOrWhiteSpace(title);
ArgumentException.ThrowIfNullOrWhiteSpace(content);
ArgumentException.ThrowIfNullOrWhiteSpace(author);
ArgumentNullException.ThrowIfNull(author);
ArgumentException.ThrowIfNullOrWhiteSpace(author.Name);

return new BlogPost
{
Expand Down
28 changes: 28 additions & 0 deletions src/Domain/ValueObjects/PostAuthor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
//=======================================================
//Copyright (c) 2026. All rights reserved.
//File Name : PostAuthor.cs
//Company : mpaulosky
//Author : Matthew Paulosky
//Solution Name : MyBlog
//Project Name : Domain
//=======================================================

namespace MyBlog.Domain.ValueObjects;

/// <summary>
/// Immutable snapshot of the post author's identity at the time the post was created.
/// Stored as an embedded document in MongoDB.
/// </summary>
public sealed record PostAuthor(
string Id,
string Name,
string Email,
IReadOnlyList<string> Roles)
Comment on lines +16 to +20
{
/// <summary>Empty/anonymous author placeholder for testing and migration purposes.</summary>
public static readonly PostAuthor Empty = new(
string.Empty,
string.Empty,
string.Empty,
[]);
}
7 changes: 7 additions & 0 deletions src/Web/Data/BlogDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,12 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
entity.ToCollection("blogposts");
entity.HasKey(p => p.Id);
entity.Property(p => p.Version).IsConcurrencyToken();
entity.OwnsOne(p => p.Author, a =>
{
a.Property(x => x.Id).HasElementName("AuthorId");
a.Property(x => x.Name).HasElementName("AuthorName");
a.Property(x => x.Email).HasElementName("AuthorEmail");
a.Property(x => x.Roles).HasElementName("AuthorRoles");
});
}
}
5 changes: 4 additions & 1 deletion src/Web/Data/BlogPostDto.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ internal sealed record BlogPostDto(
Guid Id,
string Title,
string Content,
string Author,
string AuthorId,
string AuthorName,
string AuthorEmail,
IReadOnlyList<string> AuthorRoles,
DateTime CreatedAt,
DateTime? UpdatedAt,
bool IsPublished);
3 changes: 2 additions & 1 deletion src/Web/Data/BlogPostMappings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ namespace MyBlog.Web.Data;
internal static class BlogPostMappings
{
internal static BlogPostDto ToDto(this BlogPost post) => new(
post.Id, post.Title, post.Content, post.Author,
post.Id, post.Title, post.Content,
post.Author.Id, post.Author.Name, post.Author.Email, post.Author.Roles,
post.CreatedAt, post.UpdatedAt, post.IsPublished);
}
31 changes: 23 additions & 8 deletions src/Web/Features/BlogPosts/Create/Create.razor
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
@page "/blog/create"
@using MyBlog.Domain.ValueObjects
@using MyBlog.Web.Security
@using System.Security.Claims
@inject ISender Sender
@inject NavigationManager Navigation
@inject AuthenticationStateProvider AuthStateProvider
@rendermode InteractiveServer
@attribute [Authorize(Roles = "Author,Admin")]

Expand All @@ -14,6 +18,8 @@
</div>
}

<p class="form-label mb-4">Author: <span class="font-semibold">@_authorName</span></p>

<div class="form-panel">
<EditForm Model="_model" OnValidSubmit="HandleSubmit">
<DataAnnotationsValidator />
Expand All @@ -23,10 +29,6 @@
<label class="form-label">Title</label>
<InputText class="form-input" @bind-Value="_model.Title" />
</div>
<div class="form-group">
<label class="form-label">Author</label>
<InputText class="form-input" @bind-Value="_model.Author" />
</div>
<div class="form-group">
<label class="form-label">Content</label>
<InputTextArea class="form-input" rows="8" @bind-Value="_model.Content" />
Expand All @@ -43,9 +45,25 @@
private readonly PostFormModel _model = new();
private string? _error;

private string _authorId = string.Empty;
private string _authorName = string.Empty;
private string _authorEmail = string.Empty;
private IReadOnlyList<string> _authorRoles = [];

protected override async Task OnInitializedAsync()
{
var state = await AuthStateProvider.GetAuthenticationStateAsync();
var user = state.User;
_authorId = user.FindFirst("sub")?.Value ?? string.Empty;
_authorName = user.FindFirst("name")?.Value ?? user.Identity?.Name ?? string.Empty;
_authorEmail = user.FindFirst("email")?.Value ?? string.Empty;
_authorRoles = RoleClaimsHelper.GetRoles(user);
}

private async Task HandleSubmit()
{
var result = await Sender.Send(new CreateBlogPostCommand(_model.Title, _model.Content, _model.Author));
var author = new PostAuthor(_authorId, _authorName, _authorEmail, _authorRoles);
var result = await Sender.Send(new CreateBlogPostCommand(_model.Title, _model.Content, author));
if (result.Success)
Navigation.NavigateTo("/blog");
else
Expand All @@ -57,9 +75,6 @@
[System.ComponentModel.DataAnnotations.Required]
public string Title { get; set; } = string.Empty;

[System.ComponentModel.DataAnnotations.Required]
public string Author { get; set; } = string.Empty;

[System.ComponentModel.DataAnnotations.Required]
public string Content { get; set; } = string.Empty;
}
Expand Down
3 changes: 2 additions & 1 deletion src/Web/Features/BlogPosts/Create/CreateBlogPostCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
//=======================================================

using MyBlog.Domain.Abstractions;
using MyBlog.Domain.ValueObjects;

namespace MyBlog.Web.Features.BlogPosts.Create;

internal sealed record CreateBlogPostCommand(string Title, string Content, string Author)
internal sealed record CreateBlogPostCommand(string Title, string Content, PostAuthor Author)
: IRequest<Result<Guid>>;
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@ public CreateBlogPostCommandValidator()
.NotEmpty().WithMessage("Content is required.");

RuleFor(x => x.Author)
.NotEmpty().WithMessage("Author is required.")
.MaximumLength(100).WithMessage("Author must not exceed 100 characters.");
.NotNull()
.WithMessage("Author is required.");
RuleFor(x => x.Author.Name)
.NotEmpty()
.WithMessage("Author name is required.")
Comment on lines +27 to +30
.When(x => x.Author is not null);
}
}
2 changes: 1 addition & 1 deletion src/Web/Features/BlogPosts/List/Index.razor
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ else
{
<tr>
<td>@post.Title</td>
<td>@post.Author</td>
<td>@post.AuthorName</td>
<td>@(post.IsPublished ? "Yes" : "No")</td>
<td>@post.CreatedAt.ToShortDateString()</td>
<AuthorizeView Roles="Author,Admin">
Expand Down
Loading
Loading