diff --git a/src/api/MicCheck.Api/Audit/AuditLogQueryService.cs b/src/api/MicCheck.Api/Audit/AuditLogQueryService.cs index 5cd3a77..cf4bb52 100755 --- a/src/api/MicCheck.Api/Audit/AuditLogQueryService.cs +++ b/src/api/MicCheck.Api/Audit/AuditLogQueryService.cs @@ -1,3 +1,4 @@ +using MicCheck.Api.Common; using MicCheck.Api.Data; using Microsoft.EntityFrameworkCore; @@ -5,28 +6,28 @@ namespace MicCheck.Api.Audit; public class AuditLogQueryService(IMicCheckDbContext db) { - public async Task<(int Total, IReadOnlyList Items)> ListByOrganizationAsync( + public async Task> 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<(int Total, IReadOnlyList Items)> ListByProjectAsync( + public async Task> 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<(int Total, IReadOnlyList Items)> ListByEnvironmentAsync( + public async Task> 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<(int Total, IReadOnlyList Items)> ApplyFilterAndPageAsync( + private async Task> ApplyFilterAndPageAsync( IQueryable query, AuditLogFilter filter, CancellationToken ct) { if (filter.From.HasValue) @@ -63,6 +64,6 @@ public class AuditLogQueryService(IMicCheckDbContext db) user != null ? user.FirstName + " " + user.LastName : null)) .ToListAsync(ct); - return (total, items); + return new PagedResult(total, items); } } diff --git a/src/api/MicCheck.Api/Audit/AuditLogsController.cs b/src/api/MicCheck.Api/Audit/AuditLogsController.cs index fd760e7..cc5311e 100755 --- a/src/api/MicCheck.Api/Audit/AuditLogsController.cs +++ b/src/api/MicCheck.Api/Audit/AuditLogsController.cs @@ -1,59 +1,59 @@ -using MicCheck.Api.Common.Security.Authorization; -using MicCheck.Api.Common; -using MicCheck.Api.Environments; -using MicCheck.Api.Projects; -using MicCheck.Api.Organizations; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.RateLimiting; - -namespace MicCheck.Api.Audit; - -[ApiController] -[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)] -[EnableRateLimiting("AdminApi")] -public class AuditLogsController( - AuditLogQueryService auditLogQueryService, - OrganizationService organizationService, - ProjectService projectService, - EnvironmentService environmentService) : ControllerBase -{ - [HttpGet("api/v1/organisation/{id}/audit-logs")] - public async Task>> ListByOrganization( - int id, - [FromQuery] AuditLogFilter filter, - CancellationToken ct) - { - var org = await organizationService.FindByIdAsync(id, ct); - if (org is null) return NotFound(); - - var (total, logs) = await auditLogQueryService.ListByOrganizationAsync(id, filter, ct); - return Ok(new PaginatedResponse(total, null, null, logs.ToList())); - } - - [HttpGet("api/v1/project/{projectId}/audit-logs")] - public async Task>> ListByProject( - int projectId, - [FromQuery] AuditLogFilter filter, - CancellationToken ct) - { - var project = await projectService.FindByIdAsync(projectId, ct); - if (project is null) return NotFound(); - - var (total, logs) = await auditLogQueryService.ListByProjectAsync(projectId, filter, ct); - return Ok(new PaginatedResponse(total, null, null, logs.ToList())); - } - - [HttpGet("api/v1/environment/{apiKey}/audit-logs")] - public async Task>> ListByEnvironment( - string apiKey, - [FromQuery] AuditLogFilter filter, - CancellationToken ct) - { - var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); - if (environment is null) return NotFound(); - - var (total, logs) = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, filter, ct); - return Ok(new PaginatedResponse(total, null, null, logs.ToList())); - } -} +using MicCheck.Api.Common.Security.Authorization; +using MicCheck.Api.Common; +using MicCheck.Api.Environments; +using MicCheck.Api.Projects; +using MicCheck.Api.Organizations; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; + +namespace MicCheck.Api.Audit; + +[ApiController] +[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)] +[EnableRateLimiting("AdminApi")] +public class AuditLogsController( + AuditLogQueryService auditLogQueryService, + OrganizationService organizationService, + ProjectService projectService, + EnvironmentService environmentService) : ControllerBase +{ + [HttpGet("api/v1/organisation/{id}/audit-logs")] + public async Task>> ListByOrganization( + int id, + [FromQuery] AuditLogFilter filter, + CancellationToken ct) + { + var org = await organizationService.FindByIdAsync(id, ct); + if (org is null) return NotFound(); + + var result = await auditLogQueryService.ListByOrganizationAsync(id, filter, ct); + return Ok(new PaginatedResponse(result.Total, null, null, result.Items.ToList())); + } + + [HttpGet("api/v1/project/{projectId}/audit-logs")] + public async Task>> ListByProject( + int projectId, + [FromQuery] AuditLogFilter filter, + CancellationToken ct) + { + var project = await projectService.FindByIdAsync(projectId, ct); + if (project is null) return NotFound(); + + var result = await auditLogQueryService.ListByProjectAsync(projectId, filter, ct); + return Ok(new PaginatedResponse(result.Total, null, null, result.Items.ToList())); + } + + [HttpGet("api/v1/environment/{apiKey}/audit-logs")] + public async Task>> ListByEnvironment( + string apiKey, + [FromQuery] AuditLogFilter filter, + CancellationToken ct) + { + var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); + if (environment is null) return NotFound(); + + var result = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, filter, ct); + return Ok(new PaginatedResponse(result.Total, null, null, result.Items.ToList())); + } +} diff --git a/src/api/MicCheck.Api/Common/PagedResult.cs b/src/api/MicCheck.Api/Common/PagedResult.cs new file mode 100644 index 0000000..8ba99ab --- /dev/null +++ b/src/api/MicCheck.Api/Common/PagedResult.cs @@ -0,0 +1,3 @@ +namespace MicCheck.Api.Common; + +public record PagedResult(int Total, IReadOnlyList Items); diff --git a/src/api/MicCheck.Api/Common/PaginatedResponse.cs b/src/api/MicCheck.Api/Common/PaginatedResponse.cs index 07c72aa..cb42ca3 100755 --- a/src/api/MicCheck.Api/Common/PaginatedResponse.cs +++ b/src/api/MicCheck.Api/Common/PaginatedResponse.cs @@ -1,8 +1,3 @@ namespace MicCheck.Api.Common; -public record PaginatedResponse( - int Count, - string? Next, - string? Previous, - IReadOnlyList Results -); +public record PaginatedResponse(int Count, string? Next, string? Previous, IReadOnlyList Results); diff --git a/src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKey.cs b/src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKey.cs index 8a7df52..06b3b22 100755 --- a/src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKey.cs +++ b/src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKey.cs @@ -3,11 +3,11 @@ namespace MicCheck.Api.Common.Security.ApiKeys; public class ApiKey { public int Id { get; init; } - public required string Key { get; set; } - public required string Prefix { get; set; } - public required string Name { get; set; } + public required string Key { get; init; } + public required string Prefix { get; init; } + public required string Name { get; init; } public int OrganizationId { get; init; } public bool IsActive { get; set; } - public DateTimeOffset? ExpiresAt { get; set; } + public DateTimeOffset? ExpiresAt { get; init; } public DateTimeOffset CreatedAt { get; init; } } diff --git a/src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKeyEndpoints.cs b/src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKeyEndpoints.cs index e5dd6ae..65131e5 100755 --- a/src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKeyEndpoints.cs +++ b/src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKeyEndpoints.cs @@ -13,10 +13,9 @@ public static class ApiKeyEndpoints group.MapPost("/api-keys", async (int organizationId, CreateApiKeyRequest request, ApiKeyService apiKeyService, CancellationToken ct) => { - var (key, rawKey) = await apiKeyService.CreateAsync( - organizationId, request.Name, request.ExpiresAt, ct); + var result = await apiKeyService.CreateAsync(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"); diff --git a/src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKeyService.cs b/src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKeyService.cs index c94bb50..551cafe 100755 --- a/src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKeyService.cs +++ b/src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKeyService.cs @@ -5,7 +5,7 @@ namespace MicCheck.Api.Common.Security.ApiKeys; public class ApiKeyService(IMicCheckDbContext db) { - public async Task<(ApiKey Key, string RawKey)> CreateAsync( + public async Task CreateAsync( int organizationId, string name, DateTimeOffset? expiresAt, CancellationToken ct = default) { var rawKey = ApiKeyHasher.GenerateKey(); @@ -26,7 +26,7 @@ public class ApiKeyService(IMicCheckDbContext db) db.ApiKeys.Add(apiKey); await db.SaveChangesAsync(ct); - return (apiKey, rawKey); + return new ApiKeyCreationResult(apiKey, rawKey); } public async Task> ListAsync(int organizationId, CancellationToken ct = default) @@ -48,3 +48,5 @@ public class ApiKeyService(IMicCheckDbContext db) await db.SaveChangesAsync(ct); } } + +public record ApiKeyCreationResult(ApiKey Key, string RawKey); diff --git a/src/api/MicCheck.Api/Data/DatabaseSeeder.cs b/src/api/MicCheck.Api/Data/DatabaseSeeder.cs index 82df6a7..7dc0570 100755 --- a/src/api/MicCheck.Api/Data/DatabaseSeeder.cs +++ b/src/api/MicCheck.Api/Data/DatabaseSeeder.cs @@ -7,7 +7,7 @@ using AppEnvironment = MicCheck.Api.Environments.Environment; namespace MicCheck.Api.Data; -public class DatabaseSeeder +public class DatabaseSeeder(MicCheckDbContext db, IPasswordHasher passwordHasher) { /// /// 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 /// without depending on per-run registration. /// - public const string SeedAdminEmail = "admin@miccheck.local"; - public const string SeedAdminPassword = "MicCheckQa!2026"; + private const string SeedAdminEmail = "admin@miccheck.local"; + + private const string SeedAdminPassword = "MicCheckQa!2026"; /// Deterministic environment key for the seeded Development environment, so /// HTTP-only integration/e2e tests can read flags without a direct DB connection. - public const string SeedDevelopmentEnvironmentKey = "env-qa-development"; - - private readonly MicCheckDbContext _db; - private readonly IPasswordHasher _passwordHasher; - - public DatabaseSeeder(MicCheckDbContext db, IPasswordHasher passwordHasher) - { - _db = db; - _passwordHasher = passwordHasher; - } + private const string SeedDevelopmentEnvironmentKey = "env-qa-development"; public async Task SeedAsync(CancellationToken ct = default) { - if (await _db.Organizations.AnyAsync(ct)) + if (await db.Organizations.AnyAsync(ct)) return; var organization = new Organization @@ -41,8 +33,8 @@ public class DatabaseSeeder Name = "Default", CreatedAt = DateTimeOffset.UtcNow }; - _db.Organizations.Add(organization); - await _db.SaveChangesAsync(ct); + db.Organizations.Add(organization); + await db.SaveChangesAsync(ct); var project = new Project { @@ -50,13 +42,13 @@ public class DatabaseSeeder OrganizationId = organization.Id, CreatedAt = DateTimeOffset.UtcNow }; - _db.Projects.Add(project); - await _db.SaveChangesAsync(ct); + db.Projects.Add(project); + await db.SaveChangesAsync(ct); var environmentNames = new[] { "Development", "Staging", "Production" }; foreach (var name in environmentNames) { - _db.Environments.Add(new AppEnvironment + db.Environments.Add(new AppEnvironment { Name = name, 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 { @@ -76,17 +68,17 @@ public class DatabaseSeeder CreatedAt = DateTimeOffset.UtcNow, PasswordHash = string.Empty }; - adminUser.PasswordHash = _passwordHasher.HashPassword(adminUser, SeedAdminPassword); - _db.Users.Add(adminUser); - await _db.SaveChangesAsync(ct); + adminUser.PasswordHash = passwordHasher.HashPassword(adminUser, SeedAdminPassword); + db.Users.Add(adminUser); + await db.SaveChangesAsync(ct); - _db.OrganizationUsers.Add(new OrganizationUser + db.OrganizationUsers.Add(new OrganizationUser { OrganizationId = organization.Id, UserId = adminUser.Id, Role = OrganizationRole.Admin, IsPrimary = true }); - await _db.SaveChangesAsync(ct); + await db.SaveChangesAsync(ct); } } diff --git a/src/api/MicCheck.Api/Environments/EnvironmentsController.cs b/src/api/MicCheck.Api/Environments/EnvironmentsController.cs index 6508f2d..fb8c2b6 100755 --- a/src/api/MicCheck.Api/Environments/EnvironmentsController.cs +++ b/src/api/MicCheck.Api/Environments/EnvironmentsController.cs @@ -141,8 +141,8 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); if (environment is null) return NotFound(); - var (_, logs) = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, new Audit.AuditLogFilter(), ct); - return Ok(logs.ToList()); + var result = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, new Audit.AuditLogFilter(), ct); + return Ok(result.Items.ToList()); } [HttpGet("api/v1/environment/{apiKey}/identities")] @@ -157,9 +157,9 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); if (environment is null) return NotFound(); - var (total, items) = await adminIdentityService.ListAsync(environment.Id, page, pageSize, ct); - var results = items.Select(Identities.AdminIdentityResponse.From).ToList(); - return Ok(new PaginatedResponse(total, null, null, results)); + var result = await adminIdentityService.ListAsync(environment.Id, page, pageSize, ct); + var results = result.Items.Select(Identities.AdminIdentityResponse.From).ToList(); + return Ok(new PaginatedResponse(result.Total, null, null, results)); } [HttpPost("api/v1/environment/{apiKey}/identities")] diff --git a/src/api/MicCheck.Api/Identities/AdminIdentityService.cs b/src/api/MicCheck.Api/Identities/AdminIdentityService.cs index dd6933c..f43be2e 100755 --- a/src/api/MicCheck.Api/Identities/AdminIdentityService.cs +++ b/src/api/MicCheck.Api/Identities/AdminIdentityService.cs @@ -1,3 +1,4 @@ +using MicCheck.Api.Common; using MicCheck.Api.Data; using MicCheck.Api.Features; using Microsoft.EntityFrameworkCore; @@ -6,7 +7,7 @@ namespace MicCheck.Api.Identities; public class AdminIdentityService(IMicCheckDbContext db) { - public async Task<(int Total, IReadOnlyList Items)> ListAsync( + public async Task> ListAsync( int environmentId, int page, int pageSize, CancellationToken ct = default) { var query = db.Identities @@ -20,7 +21,7 @@ public class AdminIdentityService(IMicCheckDbContext db) .Take(pageSize) .ToListAsync(ct); - return (total, items); + return new PagedResult(total, items); } public async Task CreateAsync(int environmentId, string identifier, CancellationToken ct = default) diff --git a/src/api/MicCheck.Api/Projects/.gitkeep b/src/api/MicCheck.Api/Projects/.gitkeep deleted file mode 100755 index e69de29..0000000 diff --git a/src/api/MicCheck.Api/Users/UserService.cs b/src/api/MicCheck.Api/Users/UserService.cs index d0022c9..8b6733b 100755 --- a/src/api/MicCheck.Api/Users/UserService.cs +++ b/src/api/MicCheck.Api/Users/UserService.cs @@ -9,23 +9,23 @@ public class UserService(IMicCheckDbContext db, IPasswordHasher passwordHa public async Task FindByIdAsync(int userId, CancellationToken ct = default) => await db.Users.FirstOrDefaultAsync(u => u.Id == userId && u.IsActive, ct); - public async Task<(User? User, bool EmailConflict)> UpdateProfileAsync( + public async Task UpdateProfileAsync( int userId, string firstName, string lastName, string email, CancellationToken ct = default) { 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)) { 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.LastName = lastName; user.Email = email; await db.SaveChangesAsync(ct); - return (user, false); + return new ProfileUpdateResult(user, EmailConflict: false); } public async Task ChangePasswordAsync( @@ -42,3 +42,5 @@ public class UserService(IMicCheckDbContext db, IPasswordHasher passwordHa return true; } } + +public record ProfileUpdateResult(User? User, bool EmailConflict); diff --git a/src/api/MicCheck.Api/Users/UsersController.cs b/src/api/MicCheck.Api/Users/UsersController.cs index 520de74..09bf4e1 100755 --- a/src/api/MicCheck.Api/Users/UsersController.cs +++ b/src/api/MicCheck.Api/Users/UsersController.cs @@ -39,11 +39,11 @@ public class UsersController(UserService userService) : ControllerBase string.IsNullOrWhiteSpace(request.Email)) 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); - if (emailConflict) return Conflict("Email is already in use."); - return user is null ? NotFound() : Ok(UserResponse.From(user)); + if (result.EmailConflict) return Conflict("Email is already in use."); + return result.User is null ? NotFound() : Ok(UserResponse.From(result.User)); } [HttpPost("me/change-password")]