Convert logic-free classes to records per CLAUDE.md

WebhookEvent, FeatureStateResult, ProjectPermissionRequirement, AuditLog,
Tag, and FeatureUsageDaily are plain data holders with no behavior, so
switch them to records. EF-mapped entities with identity semantics
(User, Organization, Project, Feature, Segment, etc.) stay classes,
since record value-equality and deep ToString fight EF's change tracker.

Also restore `virtual` on AuditService.RecordAsync/LogAsync, which had
been dropped and broke Mock<AuditService>-based test setups.
This commit is contained in:
2026-07-05 22:20:41 -07:00
parent 27497e31a9
commit 75595970fb
12 changed files with 26 additions and 44 deletions

View File

@@ -1,6 +1,6 @@
namespace MicCheck.Api.Audit; namespace MicCheck.Api.Audit;
public class AuditLog public record AuditLog
{ {
public int Id { get; init; } public int Id { get; init; }
public required string ResourceType { get; init; } public required string ResourceType { get; init; }

View File

@@ -6,29 +6,25 @@ namespace MicCheck.Api.Audit;
public class AuditLogQueryService(IMicCheckDbContext db) public class AuditLogQueryService(IMicCheckDbContext db)
{ {
public async Task<PagedResult<AuditLogResponse>> ListByOrganizationAsync( public async Task<PagedResult<AuditLogResponse>> ListByOrganizationAsync(int organizationId, AuditLogFilter filter, CancellationToken ct = default)
int organizationId, AuditLogFilter filter, CancellationToken ct = default)
{ {
var query = db.AuditLogs.Where(l => l.OrganizationId == organizationId); var query = db.AuditLogs.Where(l => l.OrganizationId == organizationId);
return await ApplyFilterAndPageAsync(query, filter, ct); return await ApplyFilterAndPageAsync(query, filter, ct);
} }
public async Task<PagedResult<AuditLogResponse>> ListByProjectAsync( public async Task<PagedResult<AuditLogResponse>> ListByProjectAsync(int projectId, AuditLogFilter filter, CancellationToken ct = default)
int projectId, AuditLogFilter filter, CancellationToken ct = default)
{ {
var query = db.AuditLogs.Where(l => l.ProjectId == projectId); var query = db.AuditLogs.Where(l => l.ProjectId == projectId);
return await ApplyFilterAndPageAsync(query, filter, ct); return await ApplyFilterAndPageAsync(query, filter, ct);
} }
public async Task<PagedResult<AuditLogResponse>> ListByEnvironmentAsync( public async Task<PagedResult<AuditLogResponse>> ListByEnvironmentAsync(int environmentId, AuditLogFilter filter, CancellationToken ct = default)
int environmentId, AuditLogFilter filter, CancellationToken ct = default)
{ {
var query = db.AuditLogs.Where(l => l.EnvironmentId == environmentId); var query = db.AuditLogs.Where(l => l.EnvironmentId == environmentId);
return await ApplyFilterAndPageAsync(query, filter, ct); return await ApplyFilterAndPageAsync(query, filter, ct);
} }
private async Task<PagedResult<AuditLogResponse>> ApplyFilterAndPageAsync( private async Task<PagedResult<AuditLogResponse>> ApplyFilterAndPageAsync(IQueryable<AuditLog> query, AuditLogFilter filter, CancellationToken ct)
IQueryable<AuditLog> query, AuditLogFilter filter, CancellationToken ct)
{ {
if (filter.From.HasValue) if (filter.From.HasValue)
query = query.Where(l => l.CreatedAt >= filter.From.Value); query = query.Where(l => l.CreatedAt >= filter.From.Value);
@@ -66,4 +62,4 @@ public class AuditLogQueryService(IMicCheckDbContext db)
return new PagedResult<AuditLogResponse>(total, items); return new PagedResult<AuditLogResponse>(total, items);
} }
} }

View File

@@ -33,7 +33,7 @@ public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContex
}, JsonOptions); }, JsonOptions);
} }
var actorUserId = ResolveActorUserId(); var actorUserId = GetCurrentUserId();
var log = new AuditLog var log = new AuditLog
{ {
@@ -78,7 +78,7 @@ public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContex
string? changes = null, string? changes = null,
CancellationToken ct = default) CancellationToken ct = default)
{ {
var actorUserId = ResolveActorUserId(); var actorUserId = GetCurrentUserId();
var log = new AuditLog var log = new AuditLog
{ {
@@ -112,7 +112,7 @@ public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContex
}); });
} }
private int? ResolveActorUserId() private int? GetCurrentUserId()
{ {
var claim = httpContextAccessor.HttpContext?.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; var claim = httpContextAccessor.HttpContext?.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
return int.TryParse(claim, out var id) ? id : null; return int.TryParse(claim, out var id) ? id : null;

View File

@@ -2,9 +2,4 @@ using Microsoft.AspNetCore.Authorization;
namespace MicCheck.Api.Common.Security.Authorization; namespace MicCheck.Api.Common.Security.Authorization;
public class ProjectPermissionRequirement : IAuthorizationRequirement public record ProjectPermissionRequirement(ProjectPermission Permission) : IAuthorizationRequirement;
{
public ProjectPermission Permission { get; }
public ProjectPermissionRequirement(ProjectPermission permission) => Permission = permission;
}

View File

@@ -3,12 +3,7 @@ using MicCheck.Api.Common.Validation;
namespace MicCheck.Api.Features; namespace MicCheck.Api.Features;
public record CreateFeatureRequest( public record CreateFeatureRequest(string Name, FeatureType Type, string? InitialValue, string? Description);
string Name,
FeatureType Type,
string? InitialValue,
string? Description
);
public class CreateFeatureRequestValidator : IModelValidator<CreateFeatureRequest> public class CreateFeatureRequestValidator : IModelValidator<CreateFeatureRequest>
{ {

View File

@@ -1,6 +1,6 @@
namespace MicCheck.Api.Features; namespace MicCheck.Api.Features;
public class FeatureStateResult public record FeatureStateResult
{ {
public required Feature Feature { get; init; } public required Feature Feature { get; init; }
public bool Enabled { get; init; } public bool Enabled { get; init; }

View File

@@ -1,9 +1,9 @@
namespace MicCheck.Api.Features; namespace MicCheck.Api.Features;
public class Tag public record Tag
{ {
public int Id { get; init; } public int Id { get; init; }
public required string Label { get; set; } public required string Label { get; init; }
public required string Color { get; set; } public required string Color { get; init; }
public int ProjectId { get; init; } public int ProjectId { get; init; }
} }

View File

@@ -8,23 +8,24 @@ namespace MicCheck.Api.Features;
[ApiController] [ApiController]
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)] [Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
[EnableRateLimiting("AdminApi")] [EnableRateLimiting("AdminApi")]
[Route("api/v1/project/{projectId}")]
public class TagsController(TagService tagService) : ControllerBase public class TagsController(TagService tagService) : ControllerBase
{ {
[HttpGet("api/v1/project/{projectId}/tags")] [HttpGet("tags")]
public async Task<ActionResult<IReadOnlyList<TagResponse>>> List(int projectId, CancellationToken ct) public async Task<ActionResult<IReadOnlyList<TagResponse>>> List(int projectId, CancellationToken ct)
{ {
var tags = await tagService.ListByProjectAsync(projectId, ct); var tags = await tagService.ListByProjectAsync(projectId, ct);
return Ok(tags.Select(TagResponse.From).ToList()); return Ok(tags.Select(TagResponse.From).ToList());
} }
[HttpPost("api/v1/project/{projectId}/tags")] [HttpPost("tags")]
public async Task<ActionResult<TagResponse>> Create(int projectId, CreateTagRequest request, CancellationToken ct) public async Task<ActionResult<TagResponse>> Create(int projectId, CreateTagRequest request, CancellationToken ct)
{ {
var tag = await tagService.CreateAsync(projectId, request.Label, request.Color, ct); var tag = await tagService.CreateAsync(projectId, request.Label, request.Color, ct);
return CreatedAtAction(nameof(List), new { projectId }, TagResponse.From(tag)); return CreatedAtAction(nameof(List), new { projectId }, TagResponse.From(tag));
} }
[HttpDelete("api/v1/project/{projectId}/tag/{id}")] [HttpDelete("tag/{id}")]
public async Task<IActionResult> Delete(int projectId, int id, CancellationToken ct) public async Task<IActionResult> Delete(int projectId, int id, CancellationToken ct)
{ {
var tag = await tagService.FindByIdAsync(id, ct); var tag = await tagService.FindByIdAsync(id, ct);

View File

@@ -3,10 +3,7 @@ using MicCheck.Api.Common.Validation;
namespace MicCheck.Api.Features; namespace MicCheck.Api.Features;
public record UpdateFeatureRequest( public record UpdateFeatureRequest(string Name, string? Description);
string Name,
string? Description
);
public class UpdateFeatureRequestValidator : IModelValidator<UpdateFeatureRequest> public class UpdateFeatureRequestValidator : IModelValidator<UpdateFeatureRequest>
{ {

View File

@@ -1,12 +1,12 @@
namespace MicCheck.Api.Features.Usage; namespace MicCheck.Api.Features.Usage;
public class FeatureUsageDaily public record FeatureUsageDaily
{ {
public int Id { get; init; } public int Id { get; init; }
public int EnvironmentId { get; init; } public int EnvironmentId { get; init; }
public int FeatureId { get; init; } public int FeatureId { get; init; }
public required string FeatureName { get; set; } public required string FeatureName { get; init; }
public DateOnly UsageDate { get; init; } public DateOnly UsageDate { get; init; }
public long Count { get; set; } public long Count { get; init; }
public DateTimeOffset UpdatedAt { get; set; } public DateTimeOffset UpdatedAt { get; init; }
} }

View File

@@ -4,6 +4,4 @@ public record TopFeatureUsage(int FeatureId, string FeatureName, long Count);
public record DailyUsage(DateOnly Date, long TotalCount, IReadOnlyList<TopFeatureUsage> Features); public record DailyUsage(DateOnly Date, long TotalCount, IReadOnlyList<TopFeatureUsage> Features);
public record DashboardUsageResponse( public record DashboardUsageResponse(IReadOnlyList<TopFeatureUsage> TopFeaturesLastDay, IReadOnlyList<DailyUsage> DailyUsage);
IReadOnlyList<TopFeatureUsage> TopFeaturesLastDay,
IReadOnlyList<DailyUsage> DailyUsage);

View File

@@ -1,6 +1,6 @@
namespace MicCheck.Api.Webhooks; namespace MicCheck.Api.Webhooks;
public class WebhookEvent public record WebhookEvent
{ {
public required string EventType { get; init; } public required string EventType { get; init; }
public int? EnvironmentId { get; init; } public int? EnvironmentId { get; init; }