Replace tuple return types with named records

CLAUDE.md prohibits tuples as return types. Add PagedResult<T>,
ProfileUpdateResult, and ApiKeyCreationResult records and update all
callers (AuditLogQueryService, AdminIdentityService, UserService,
ApiKeyService and their controllers/endpoints).
This commit is contained in:
2026-07-04 21:21:29 -07:00
parent b4a666ebf0
commit 643188ca9e
13 changed files with 113 additions and 118 deletions

View File

@@ -1,3 +1,4 @@
using MicCheck.Api.Common;
using MicCheck.Api.Data; using MicCheck.Api.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -5,28 +6,28 @@ namespace MicCheck.Api.Audit;
public class AuditLogQueryService(IMicCheckDbContext db) public class AuditLogQueryService(IMicCheckDbContext db)
{ {
public async Task<(int Total, IReadOnlyList<AuditLogResponse> Items)> 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<(int Total, IReadOnlyList<AuditLogResponse> Items)> 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<(int Total, IReadOnlyList<AuditLogResponse> Items)> 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<(int Total, IReadOnlyList<AuditLogResponse> Items)> 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)
@@ -63,6 +64,6 @@ public class AuditLogQueryService(IMicCheckDbContext db)
user != null ? user.FirstName + " " + user.LastName : null)) user != null ? user.FirstName + " " + user.LastName : null))
.ToListAsync(ct); .ToListAsync(ct);
return (total, items); return new PagedResult<AuditLogResponse>(total, items);
} }
} }

View File

@@ -1,59 +1,59 @@
using MicCheck.Api.Common.Security.Authorization; using MicCheck.Api.Common.Security.Authorization;
using MicCheck.Api.Common; using MicCheck.Api.Common;
using MicCheck.Api.Environments; using MicCheck.Api.Environments;
using MicCheck.Api.Projects; using MicCheck.Api.Projects;
using MicCheck.Api.Organizations; using MicCheck.Api.Organizations;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
namespace MicCheck.Api.Audit; namespace MicCheck.Api.Audit;
[ApiController] [ApiController]
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)] [Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
[EnableRateLimiting("AdminApi")] [EnableRateLimiting("AdminApi")]
public class AuditLogsController( public class AuditLogsController(
AuditLogQueryService auditLogQueryService, AuditLogQueryService auditLogQueryService,
OrganizationService organizationService, OrganizationService organizationService,
ProjectService projectService, ProjectService projectService,
EnvironmentService environmentService) : ControllerBase EnvironmentService environmentService) : ControllerBase
{ {
[HttpGet("api/v1/organisation/{id}/audit-logs")] [HttpGet("api/v1/organisation/{id}/audit-logs")]
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByOrganization( public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByOrganization(
int id, int id,
[FromQuery] AuditLogFilter filter, [FromQuery] AuditLogFilter filter,
CancellationToken ct) CancellationToken ct)
{ {
var org = await organizationService.FindByIdAsync(id, ct); var org = await organizationService.FindByIdAsync(id, ct);
if (org is null) return NotFound(); if (org is null) return NotFound();
var (total, logs) = await auditLogQueryService.ListByOrganizationAsync(id, filter, ct); var result = await auditLogQueryService.ListByOrganizationAsync(id, filter, ct);
return Ok(new PaginatedResponse<AuditLogResponse>(total, null, null, logs.ToList())); return Ok(new PaginatedResponse<AuditLogResponse>(result.Total, null, null, result.Items.ToList()));
} }
[HttpGet("api/v1/project/{projectId}/audit-logs")] [HttpGet("api/v1/project/{projectId}/audit-logs")]
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByProject( public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByProject(
int projectId, int projectId,
[FromQuery] AuditLogFilter filter, [FromQuery] AuditLogFilter filter,
CancellationToken ct) CancellationToken ct)
{ {
var project = await projectService.FindByIdAsync(projectId, ct); var project = await projectService.FindByIdAsync(projectId, ct);
if (project is null) return NotFound(); if (project is null) return NotFound();
var (total, logs) = await auditLogQueryService.ListByProjectAsync(projectId, filter, ct); var result = await auditLogQueryService.ListByProjectAsync(projectId, filter, ct);
return Ok(new PaginatedResponse<AuditLogResponse>(total, null, null, logs.ToList())); return Ok(new PaginatedResponse<AuditLogResponse>(result.Total, null, null, result.Items.ToList()));
} }
[HttpGet("api/v1/environment/{apiKey}/audit-logs")] [HttpGet("api/v1/environment/{apiKey}/audit-logs")]
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByEnvironment( public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByEnvironment(
string apiKey, string apiKey,
[FromQuery] AuditLogFilter filter, [FromQuery] AuditLogFilter filter,
CancellationToken ct) CancellationToken ct)
{ {
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
if (environment is null) return NotFound(); if (environment is null) return NotFound();
var (total, logs) = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, filter, ct); var result = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, filter, ct);
return Ok(new PaginatedResponse<AuditLogResponse>(total, null, null, logs.ToList())); return Ok(new PaginatedResponse<AuditLogResponse>(result.Total, null, null, result.Items.ToList()));
} }
} }

View File

@@ -0,0 +1,3 @@
namespace MicCheck.Api.Common;
public record PagedResult<T>(int Total, IReadOnlyList<T> Items);

View File

@@ -1,8 +1,3 @@
namespace MicCheck.Api.Common; namespace MicCheck.Api.Common;
public record PaginatedResponse<T>( public record PaginatedResponse<T>(int Count, string? Next, string? Previous, IReadOnlyList<T> Results);
int Count,
string? Next,
string? Previous,
IReadOnlyList<T> Results
);

View File

@@ -3,11 +3,11 @@ namespace MicCheck.Api.Common.Security.ApiKeys;
public class ApiKey public class ApiKey
{ {
public int Id { get; init; } public int Id { get; init; }
public required string Key { get; set; } public required string Key { get; init; }
public required string Prefix { get; set; } public required string Prefix { get; init; }
public required string Name { get; set; } public required string Name { get; init; }
public int OrganizationId { get; init; } public int OrganizationId { get; init; }
public bool IsActive { get; set; } public bool IsActive { get; set; }
public DateTimeOffset? ExpiresAt { get; set; } public DateTimeOffset? ExpiresAt { get; init; }
public DateTimeOffset CreatedAt { get; init; } public DateTimeOffset CreatedAt { get; init; }
} }

View File

@@ -13,10 +13,9 @@ public static class ApiKeyEndpoints
group.MapPost("/api-keys", async (int organizationId, CreateApiKeyRequest request, ApiKeyService apiKeyService, CancellationToken ct) => group.MapPost("/api-keys", async (int organizationId, CreateApiKeyRequest request, ApiKeyService apiKeyService, CancellationToken ct) =>
{ {
var (key, rawKey) = await apiKeyService.CreateAsync( var result = await apiKeyService.CreateAsync(organizationId, request.Name, request.ExpiresAt, ct);
organizationId, request.Name, request.ExpiresAt, ct);
return Results.Ok(new CreateApiKeyResponse(key.Id, key.Name, rawKey, key.Prefix, key.ExpiresAt)); return Results.Ok(new CreateApiKeyResponse(result.Key.Id, result.Key.Name, result.RawKey, result.Key.Prefix, result.Key.ExpiresAt));
}).WithName("CreateApiKey"); }).WithName("CreateApiKey");

View File

@@ -5,7 +5,7 @@ namespace MicCheck.Api.Common.Security.ApiKeys;
public class ApiKeyService(IMicCheckDbContext db) public class ApiKeyService(IMicCheckDbContext db)
{ {
public async Task<(ApiKey Key, string RawKey)> CreateAsync( public async Task<ApiKeyCreationResult> CreateAsync(
int organizationId, string name, DateTimeOffset? expiresAt, CancellationToken ct = default) int organizationId, string name, DateTimeOffset? expiresAt, CancellationToken ct = default)
{ {
var rawKey = ApiKeyHasher.GenerateKey(); var rawKey = ApiKeyHasher.GenerateKey();
@@ -26,7 +26,7 @@ public class ApiKeyService(IMicCheckDbContext db)
db.ApiKeys.Add(apiKey); db.ApiKeys.Add(apiKey);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return (apiKey, rawKey); return new ApiKeyCreationResult(apiKey, rawKey);
} }
public async Task<IReadOnlyList<ApiKey>> ListAsync(int organizationId, CancellationToken ct = default) public async Task<IReadOnlyList<ApiKey>> ListAsync(int organizationId, CancellationToken ct = default)
@@ -48,3 +48,5 @@ public class ApiKeyService(IMicCheckDbContext db)
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
} }
} }
public record ApiKeyCreationResult(ApiKey Key, string RawKey);

View File

@@ -7,7 +7,7 @@ using AppEnvironment = MicCheck.Api.Environments.Environment;
namespace MicCheck.Api.Data; namespace MicCheck.Api.Data;
public class DatabaseSeeder public class DatabaseSeeder(MicCheckDbContext db, IPasswordHasher<User> passwordHasher)
{ {
/// <summary> /// <summary>
/// Deterministic credentials for the development/QA seed admin user. Only ever created when /// Deterministic credentials for the development/QA seed admin user. Only ever created when
@@ -15,25 +15,17 @@ public class DatabaseSeeder
/// Used by the API integration suite and the Playwright admin e2e suite to authenticate /// Used by the API integration suite and the Playwright admin e2e suite to authenticate
/// without depending on per-run registration. /// without depending on per-run registration.
/// </summary> /// </summary>
public const string SeedAdminEmail = "admin@miccheck.local"; private const string SeedAdminEmail = "admin@miccheck.local";
public const string SeedAdminPassword = "MicCheckQa!2026";
private const string SeedAdminPassword = "MicCheckQa!2026";
/// <summary>Deterministic environment key for the seeded Development environment, so /// <summary>Deterministic environment key for the seeded Development environment, so
/// HTTP-only integration/e2e tests can read flags without a direct DB connection.</summary> /// HTTP-only integration/e2e tests can read flags without a direct DB connection.</summary>
public const string SeedDevelopmentEnvironmentKey = "env-qa-development"; private const string SeedDevelopmentEnvironmentKey = "env-qa-development";
private readonly MicCheckDbContext _db;
private readonly IPasswordHasher<User> _passwordHasher;
public DatabaseSeeder(MicCheckDbContext db, IPasswordHasher<User> passwordHasher)
{
_db = db;
_passwordHasher = passwordHasher;
}
public async Task SeedAsync(CancellationToken ct = default) public async Task SeedAsync(CancellationToken ct = default)
{ {
if (await _db.Organizations.AnyAsync(ct)) if (await db.Organizations.AnyAsync(ct))
return; return;
var organization = new Organization var organization = new Organization
@@ -41,8 +33,8 @@ public class DatabaseSeeder
Name = "Default", Name = "Default",
CreatedAt = DateTimeOffset.UtcNow CreatedAt = DateTimeOffset.UtcNow
}; };
_db.Organizations.Add(organization); db.Organizations.Add(organization);
await _db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
var project = new Project var project = new Project
{ {
@@ -50,13 +42,13 @@ public class DatabaseSeeder
OrganizationId = organization.Id, OrganizationId = organization.Id,
CreatedAt = DateTimeOffset.UtcNow CreatedAt = DateTimeOffset.UtcNow
}; };
_db.Projects.Add(project); db.Projects.Add(project);
await _db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
var environmentNames = new[] { "Development", "Staging", "Production" }; var environmentNames = new[] { "Development", "Staging", "Production" };
foreach (var name in environmentNames) foreach (var name in environmentNames)
{ {
_db.Environments.Add(new AppEnvironment db.Environments.Add(new AppEnvironment
{ {
Name = name, Name = name,
ApiKey = name == "Development" ? SeedDevelopmentEnvironmentKey : $"env-{Guid.NewGuid():N}", ApiKey = name == "Development" ? SeedDevelopmentEnvironmentKey : $"env-{Guid.NewGuid():N}",
@@ -65,7 +57,7 @@ public class DatabaseSeeder
}); });
} }
await _db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
var adminUser = new User var adminUser = new User
{ {
@@ -76,17 +68,17 @@ public class DatabaseSeeder
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
PasswordHash = string.Empty PasswordHash = string.Empty
}; };
adminUser.PasswordHash = _passwordHasher.HashPassword(adminUser, SeedAdminPassword); adminUser.PasswordHash = passwordHasher.HashPassword(adminUser, SeedAdminPassword);
_db.Users.Add(adminUser); db.Users.Add(adminUser);
await _db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
_db.OrganizationUsers.Add(new OrganizationUser db.OrganizationUsers.Add(new OrganizationUser
{ {
OrganizationId = organization.Id, OrganizationId = organization.Id,
UserId = adminUser.Id, UserId = adminUser.Id,
Role = OrganizationRole.Admin, Role = OrganizationRole.Admin,
IsPrimary = true IsPrimary = true
}); });
await _db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
} }
} }

View File

@@ -141,8 +141,8 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
if (environment is null) return NotFound(); if (environment is null) return NotFound();
var (_, logs) = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, new Audit.AuditLogFilter(), ct); var result = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, new Audit.AuditLogFilter(), ct);
return Ok(logs.ToList()); return Ok(result.Items.ToList());
} }
[HttpGet("api/v1/environment/{apiKey}/identities")] [HttpGet("api/v1/environment/{apiKey}/identities")]
@@ -157,9 +157,9 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
if (environment is null) return NotFound(); if (environment is null) return NotFound();
var (total, items) = await adminIdentityService.ListAsync(environment.Id, page, pageSize, ct); var result = await adminIdentityService.ListAsync(environment.Id, page, pageSize, ct);
var results = items.Select(Identities.AdminIdentityResponse.From).ToList(); var results = result.Items.Select(Identities.AdminIdentityResponse.From).ToList();
return Ok(new PaginatedResponse<Identities.AdminIdentityResponse>(total, null, null, results)); return Ok(new PaginatedResponse<Identities.AdminIdentityResponse>(result.Total, null, null, results));
} }
[HttpPost("api/v1/environment/{apiKey}/identities")] [HttpPost("api/v1/environment/{apiKey}/identities")]

View File

@@ -1,3 +1,4 @@
using MicCheck.Api.Common;
using MicCheck.Api.Data; using MicCheck.Api.Data;
using MicCheck.Api.Features; using MicCheck.Api.Features;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -6,7 +7,7 @@ namespace MicCheck.Api.Identities;
public class AdminIdentityService(IMicCheckDbContext db) public class AdminIdentityService(IMicCheckDbContext db)
{ {
public async Task<(int Total, IReadOnlyList<Identity> Items)> ListAsync( public async Task<PagedResult<Identity>> ListAsync(
int environmentId, int page, int pageSize, CancellationToken ct = default) int environmentId, int page, int pageSize, CancellationToken ct = default)
{ {
var query = db.Identities var query = db.Identities
@@ -20,7 +21,7 @@ public class AdminIdentityService(IMicCheckDbContext db)
.Take(pageSize) .Take(pageSize)
.ToListAsync(ct); .ToListAsync(ct);
return (total, items); return new PagedResult<Identity>(total, items);
} }
public async Task<Identity?> CreateAsync(int environmentId, string identifier, CancellationToken ct = default) public async Task<Identity?> CreateAsync(int environmentId, string identifier, CancellationToken ct = default)

View File

@@ -9,23 +9,23 @@ public class UserService(IMicCheckDbContext db, IPasswordHasher<User> passwordHa
public async Task<User?> FindByIdAsync(int userId, CancellationToken ct = default) => public async Task<User?> FindByIdAsync(int userId, CancellationToken ct = default) =>
await db.Users.FirstOrDefaultAsync(u => u.Id == userId && u.IsActive, ct); await db.Users.FirstOrDefaultAsync(u => u.Id == userId && u.IsActive, ct);
public async Task<(User? User, bool EmailConflict)> UpdateProfileAsync( public async Task<ProfileUpdateResult> UpdateProfileAsync(
int userId, string firstName, string lastName, string email, CancellationToken ct = default) int userId, string firstName, string lastName, string email, CancellationToken ct = default)
{ {
var user = await db.Users.FirstOrDefaultAsync(u => u.Id == userId && u.IsActive, ct); var user = await db.Users.FirstOrDefaultAsync(u => u.Id == userId && u.IsActive, ct);
if (user is null) return (null, false); if (user is null) return new ProfileUpdateResult(null, EmailConflict: false);
if (!string.Equals(user.Email, email, StringComparison.OrdinalIgnoreCase)) if (!string.Equals(user.Email, email, StringComparison.OrdinalIgnoreCase))
{ {
var emailTaken = await db.Users.AnyAsync(u => u.Email == email && u.Id != userId, ct); var emailTaken = await db.Users.AnyAsync(u => u.Email == email && u.Id != userId, ct);
if (emailTaken) return (null, true); if (emailTaken) return new ProfileUpdateResult(null, EmailConflict: true);
} }
user.FirstName = firstName; user.FirstName = firstName;
user.LastName = lastName; user.LastName = lastName;
user.Email = email; user.Email = email;
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return (user, false); return new ProfileUpdateResult(user, EmailConflict: false);
} }
public async Task<bool> ChangePasswordAsync( public async Task<bool> ChangePasswordAsync(
@@ -42,3 +42,5 @@ public class UserService(IMicCheckDbContext db, IPasswordHasher<User> passwordHa
return true; return true;
} }
} }
public record ProfileUpdateResult(User? User, bool EmailConflict);

View File

@@ -39,11 +39,11 @@ public class UsersController(UserService userService) : ControllerBase
string.IsNullOrWhiteSpace(request.Email)) string.IsNullOrWhiteSpace(request.Email))
return BadRequest("First name, last name, and email are required."); return BadRequest("First name, last name, and email are required.");
var (user, emailConflict) = await userService.UpdateProfileAsync( var result = await userService.UpdateProfileAsync(
userId.Value, request.FirstName.Trim(), request.LastName.Trim(), request.Email.Trim(), ct); userId.Value, request.FirstName.Trim(), request.LastName.Trim(), request.Email.Trim(), ct);
if (emailConflict) return Conflict("Email is already in use."); if (result.EmailConflict) return Conflict("Email is already in use.");
return user is null ? NotFound() : Ok(UserResponse.From(user)); return result.User is null ? NotFound() : Ok(UserResponse.From(result.User));
} }
[HttpPost("me/change-password")] [HttpPost("me/change-password")]