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:
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
3
src/api/MicCheck.Api/Common/PagedResult.cs
Normal file
3
src/api/MicCheck.Api/Common/PagedResult.cs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
namespace MicCheck.Api.Common;
|
||||||
|
|
||||||
|
public record PagedResult<T>(int Total, IReadOnlyList<T> Items);
|
||||||
@@ -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
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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")]
|
||||||
|
|||||||
Reference in New Issue
Block a user