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;
public class AuditLog
public record AuditLog
{
public int Id { get; init; }
public required string ResourceType { get; init; }

View File

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

View File

@@ -33,7 +33,7 @@ public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContex
}, JsonOptions);
}
var actorUserId = ResolveActorUserId();
var actorUserId = GetCurrentUserId();
var log = new AuditLog
{
@@ -78,7 +78,7 @@ public class AuditService(IMicCheckDbContext db, IHttpContextAccessor httpContex
string? changes = null,
CancellationToken ct = default)
{
var actorUserId = ResolveActorUserId();
var actorUserId = GetCurrentUserId();
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;
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;
public class ProjectPermissionRequirement : IAuthorizationRequirement
{
public ProjectPermission Permission { get; }
public ProjectPermissionRequirement(ProjectPermission permission) => Permission = permission;
}
public record ProjectPermissionRequirement(ProjectPermission Permission) : IAuthorizationRequirement;

View File

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

View File

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

View File

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

View File

@@ -8,23 +8,24 @@ namespace MicCheck.Api.Features;
[ApiController]
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
[EnableRateLimiting("AdminApi")]
[Route("api/v1/project/{projectId}")]
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)
{
var tags = await tagService.ListByProjectAsync(projectId, ct);
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)
{
var tag = await tagService.CreateAsync(projectId, request.Label, request.Color, ct);
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)
{
var tag = await tagService.FindByIdAsync(id, ct);

View File

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

View File

@@ -1,12 +1,12 @@
namespace MicCheck.Api.Features.Usage;
public class FeatureUsageDaily
public record FeatureUsageDaily
{
public int Id { get; init; }
public int EnvironmentId { 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 long Count { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public long Count { get; init; }
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 DashboardUsageResponse(
IReadOnlyList<TopFeatureUsage> TopFeaturesLastDay,
IReadOnlyList<DailyUsage> DailyUsage);
public record DashboardUsageResponse(IReadOnlyList<TopFeatureUsage> TopFeaturesLastDay, IReadOnlyList<DailyUsage> DailyUsage);

View File

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