Skip to content

Comments

Refactor/identity groups code review#1209

Closed
cesarcastrocuba wants to merge 17 commits intofullstackhero:developfrom
cesarcastrocuba:refactor/identity-groups-code-review
Closed

Refactor/identity groups code review#1209
cesarcastrocuba wants to merge 17 commits intofullstackhero:developfrom
cesarcastrocuba:refactor/identity-groups-code-review

Conversation

@cesarcastrocuba
Copy link

refactor(identity,auditing): code review improvements — Groups feature & Auditing endpoints

Summary

This PR applies a set of code review improvements to the Identity Groups feature and all Auditing endpoints, focusing on correctness, performance, and consistency with FSH conventions.


Changes

🏗️ Identity — Group entity

IAuditableEntity implementation

  • Group now implements IAuditableEntity
  • Renamed CreatedAt/ModifiedAt (DateTime) → CreatedOnUtc/LastModifiedOnUtc (DateTimeOffset) to match the FSH BuildingBlocks contract
  • GroupConfiguration.cs uses .HasColumnName() to preserve existing DB column names (CreatedAt, ModifiedAt, ModifiedBy) — no migration needed

Encapsulated soft-delete logic

  • IsDeleted, DeletedOnUtc, DeletedBy setters changed to private
  • Added Group.Delete(string? deletedBy) domain method
  • DeleteGroupCommandHandler now calls group.Delete() instead of mutating properties directly

⚡ Identity — Query optimizations

GetGroupByIdQueryHandler

  • Replaced 3 separate DB queries (group + member count + role names) with a single EF Select() projection — eliminates N+1 risk

CreateGroupCommandHandler

  • Roles were fetched twice (once for validation, once to build the response). Now fetched once as (Id, Name) tuples — one fewer DB roundtrip per request

🔌 TypedResults on all endpoints

All endpoints updated to return TypedResults for proper OpenAPI documentation and type safety:

  • Group endpoints (8): CreateGroup, GetGroups, GetGroupById, UpdateGroup, AddUsersToGroup, GetGroupMembersTypedResults.Ok()
  • Delete endpoints (4): DeleteGroup, RemoveUserFromGroup, DeleteRole, DeleteUser → TypedResults.Ok(result) with TODO comment

⚠️ These should return TypedResults.NoContent() (HTTP 204) per REST conventions. Blocked by the NSwag-generated Blazor client (Generated.cs) which was generated without the original .nswag config and cannot handle 204 without breaking existing interface contracts. A future PR should add the .nswag config file and regenerate the client properly.

  • Auditing endpoints (7): GetAuditById, GetAuditSummary, GetAudits, GetAuditsByCorrelation, GetAuditsByTrace, GetExceptionAudits, GetSecurityAuditsTypedResults.Ok()

📋 DTO & handler updates

  • GroupDto: DateTime CreatedAtDateTimeOffset CreatedOnUtc
  • Updated all handlers that map to GroupDto: GetGroupsQueryHandler, UpdateGroupCommandHandler, GetUserGroupsQueryHandler

Verification

dotnet build src/FSH.Framework.slnx → 0 errors dotnet test src/FSH.Framework.slnx → 462 passed

One pre-existing test failure: RefreshTokenCommandHandlerTests (NullReferenceException in test setup). Confirmed failing on origin/develop before these changes — unrelated to this PR.

César Castro added 9 commits February 21, 2026 18:48
… and Auditing features

## Changes

### Identity - Groups feature

**feat: Group entity implements IAuditableEntity**
- Add IAuditableEntity interface to Group domain entity
- Rename CreatedAt/ModifiedAt (DateTime) to CreatedOnUtc/LastModifiedOnUtc (DateTimeOffset)
  to align with FSH BuildingBlocks IAuditableEntity contract
- Update GroupConfiguration.cs with HasColumnName() mapping to preserve DB column
  names (CreatedAt, ModifiedAt, ModifiedBy) - no migration required

**fix: encapsulate ISoftDeletable mutation in Group entity**
- Change IsDeleted, DeletedOnUtc, DeletedBy from public set to private set
- Add Group.Delete(string? deletedBy) domain method to encapsulate soft-delete logic
- Update DeleteGroupCommandHandler to call group.Delete() instead of
  directly mutating soft-delete properties

**perf: optimize GetGroupByIdQueryHandler to single DB round-trip**
- Replace 3 separate DB queries (group + member count + role names) with
  a single EF Select() projection
- Eliminates N+1 risk on member count and role name lookups

**perf: optimize CreateGroupCommandHandler to avoid double role query**
- Fetch role Id+Name together in a single query for validation
- Reuse result for both invalid-role detection and response DTO construction
- Eliminates one DB round-trip per CreateGroup request

**fix: wrap all Group endpoints with TypedResults**
- CreateGroupEndpoint, GetGroupsEndpoint, GetGroupByIdEndpoint,
  UpdateGroupEndpoint, AddUsersToGroupEndpoint, GetGroupMembersEndpoint: TypedResults.Ok()
- DeleteGroupEndpoint, RemoveUserFromGroupEndpoint: TypedResults.NoContent() (HTTP 204)

**fix: update GroupDto and all handlers to use CreatedOnUtc**
- GroupDto: DateTime CreatedAt -> DateTimeOffset CreatedOnUtc
- GetGroupsQueryHandler, UpdateGroupCommandHandler, GetUserGroupsQueryHandler updated

### Auditing

**fix: wrap all Auditing endpoints with TypedResults.Ok()**
- GetAuditByIdEndpoint, GetAuditSummaryEndpoint, GetAuditsEndpoint,
  GetAuditsByCorrelationEndpoint, GetAuditsByTraceEndpoint,
  GetExceptionAuditsEndpoint, GetSecurityAuditsEndpoint

## Verification
- Build: 0 errors
- Tests: 462 passed, 1 pre-existing failure (RefreshTokenCommandHandlerTests,
  NullReferenceException in test setup - unrelated to these changes)
…ix Blazor client

The NSwag-generated Blazor client (Generated.cs) uses the OpenAPI response
codes declared via .Produces() to determine which HTTP status codes to handle.
Without .Produces(Status204NoContent), NSwag defaults to expecting 200 OK and
throws ApiException when the endpoint correctly returns 204 NoContent.

This pattern is already established in ResetTenantThemeEndpoint, which works
correctly because it declares .Produces(StatusCodes.Status204NoContent).

## Endpoints fixed

**Groups (introduced in previous commit):**
- DeleteGroupEndpoint: add Produces(204/401/403)
- RemoveUserFromGroupEndpoint: add Produces(204/401/403)

**Pre-existing bugs (same root cause, fixed opportunistically):**
- DeleteRoleEndpoint: add Produces(204/401/403)
- DeleteUserEndpoint: add Produces(204/401/403)

## Root cause

Without .Produces(StatusCodes.Status204NoContent), the OpenAPI spec documents
the response as 200 OK. NSwag then generates:

    if (status_ == 200) { return; }
    else { throw new ApiException(...); }  // throws on 204

With .Produces(StatusCodes.Status204NoContent), NSwag generates:

    if (status_ == 204) { return; }  // correctly handles NoContent
…on delete endpoints

The previous Generated.cs was generated when delete endpoints returned 200 OK.
After adding TypedResults.NoContent() and .Produces(Status204NoContent) to all
delete endpoints, the client must be regenerated so NSwag emits the correct
status code checks.

Changes in Generated.cs:
- RolesDeleteAsync: status_ == 200 -> status_ == 204
- UsersDeleteAsync: status_ == 200 -> status_ == 204
- GroupsDeleteAsync: status_ == 200 -> status_ == 204
- RemoveUserFromGroupAsync: status_ == 200 -> status_ == 204

Generated with: NSwag.ConsoleCore v14.6.3 from https://localhost:7030/openapi/v1.json
…andling on delete endpoints"

This reverts commit 087b3e5.
…ontent

The NSwag-generated client only handled HTTP 200 for delete operations.
Since the API now correctly returns 204 NoContent (TypedResults.NoContent),
the Blazor client was throwing ApiException on every delete.

Minimal surgical patch - 4 lines changed, no structural modifications:
- RolesDeleteAsync:         status_ == 200  ->  status_ == 200 || status_ == 204
- UsersDeleteAsync:         status_ == 200  ->  status_ == 200 || status_ == 204
- GroupsDeleteAsync:        status_ == 200  ->  status_ == 200 || status_ == 204
- RemoveUserFromGroupAsync: status_ == 200  ->  status_ == 200 || status_ == 204

Note: a full client regeneration was attempted but changed the client class
structure, breaking existing type references. This patch is the safe alternative.
…k() with TODO

The NSwag-generated GroupsDeleteAsync and RemoveUserFromGroupAsync return
Task<Unit> and attempt to deserialize the response body. With TypedResults.NoContent()
(HTTP 204 - empty body), this causes:
  'Could not deserialize the response body stream as Unit. Status: 204'

Root cause: the generated client contract uses Task<Unit> (expects a body)
instead of Task (void). Fixing this requires regenerating Generated.cs with
the original NSwag configuration, which is not yet available in the repo.

Changes:
- DeleteGroupEndpoint: NoContent() -> Ok(result) + Produces(200) + TODO comment
- RemoveUserFromGroupEndpoint: NoContent() -> Ok(result) + Produces(200) + TODO comment
- Generated.cs GroupsDeleteAsync: revert status_==200||204 back to status_==200
- Generated.cs RemoveUserFromGroupAsync: revert status_==200||204 back to status_==200

Note: DeleteRoleEndpoint and DeleteUserEndpoint correctly return NoContent()
(their generated methods are Task void - no body deserialization) and remain
properly fixed with the status_==200||204 patch in Generated.cs.
… future NoContent migration

For consistency, all 4 delete endpoints now return TypedResults.Ok(result)
while the Blazor NSwag client cannot properly handle 204 NoContent.

Until the client is regenerated with the original NSwag configuration,
both endpoints return 200 OK to avoid Blazor client failures.

- DeleteRoleEndpoint: NoContent() -> Ok(result), Produces(200/401/403) + TODO
- DeleteUserEndpoint: NoContent() -> Ok(result), Produces(200/401/403) + TODO
…=200 only

RolesDeleteAsync and UsersDeleteAsync were incorrectly left with
status_==200||status_==204 from a previous patch attempt.

Since both endpoints now return TypedResults.Ok() (HTTP 200), the client
only needs to handle status 200. Reverted to match the original generated code.

Generated.cs is otherwise unchanged from its original state.
…dler

ValueTuple.Create() is not translatable to SQL by the EF Core LINQ provider,
causing a runtime exception when querying roles.

Fix: project to an anonymous type inside the EF query (which EF can translate),
then convert to value tuples in memory after ToListAsync().

Before:
  .Select(r => ValueTuple.Create(r.Id, r.Name!))  // throws at runtime

After:
  .Select(r => new { r.Id, r.Name })               // translated to SQL
  then: rawRoles.Select(r => (r.Id, r.Name!))      // in-memory conversion
@cesarcastrocuba cesarcastrocuba marked this pull request as draft February 24, 2026 08:00
@cesarcastrocuba cesarcastrocuba deleted the refactor/identity-groups-code-review branch February 24, 2026 18:08
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