Adding Admin API
This commit is contained in:
31
src/api/MicCheck.Api/Audit/AuditLogQueryService.cs
Normal file
31
src/api/MicCheck.Api/Audit/AuditLogQueryService.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using MicCheck.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MicCheck.Api.Audit;
|
||||
|
||||
public class AuditLogQueryService(MicCheckDbContext db)
|
||||
{
|
||||
public async Task<IReadOnlyList<AuditLog>> ListByOrganizationAsync(int organizationId, CancellationToken ct = default)
|
||||
{
|
||||
return await db.AuditLogs
|
||||
.Where(l => l.OrganizationId == organizationId)
|
||||
.OrderByDescending(l => l.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AuditLog>> ListByProjectAsync(int projectId, CancellationToken ct = default)
|
||||
{
|
||||
return await db.AuditLogs
|
||||
.Where(l => l.ProjectId == projectId)
|
||||
.OrderByDescending(l => l.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AuditLog>> ListByEnvironmentAsync(int environmentId, CancellationToken ct = default)
|
||||
{
|
||||
return await db.AuditLogs
|
||||
.Where(l => l.EnvironmentId == environmentId)
|
||||
.OrderByDescending(l => l.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
}
|
||||
27
src/api/MicCheck.Api/Audit/AuditLogResponse.cs
Normal file
27
src/api/MicCheck.Api/Audit/AuditLogResponse.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
namespace MicCheck.Api.Audit;
|
||||
|
||||
public record AuditLogResponse(
|
||||
int Id,
|
||||
string ResourceType,
|
||||
string ResourceId,
|
||||
string Action,
|
||||
string? Changes,
|
||||
int OrganizationId,
|
||||
int? ProjectId,
|
||||
int? EnvironmentId,
|
||||
int? ActorUserId,
|
||||
DateTimeOffset CreatedAt
|
||||
)
|
||||
{
|
||||
public static AuditLogResponse From(AuditLog log) => new(
|
||||
log.Id,
|
||||
log.ResourceType,
|
||||
log.ResourceId,
|
||||
log.Action,
|
||||
log.Changes,
|
||||
log.OrganizationId,
|
||||
log.ProjectId,
|
||||
log.EnvironmentId,
|
||||
log.ActorUserId,
|
||||
log.CreatedAt);
|
||||
}
|
||||
37
src/api/MicCheck.Api/Audit/AuditLogsController.cs
Normal file
37
src/api/MicCheck.Api/Audit/AuditLogsController.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
using MicCheck.Api.Authorization;
|
||||
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) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/v1/organisations/{id}/audit-logs")]
|
||||
public async Task<ActionResult<IReadOnlyList<AuditLogResponse>>> ListByOrganization(int id, CancellationToken ct)
|
||||
{
|
||||
var org = await organizationService.FindByIdAsync(id, ct);
|
||||
if (org is null) return NotFound();
|
||||
|
||||
var logs = await auditLogQueryService.ListByOrganizationAsync(id, ct);
|
||||
return Ok(logs.Select(AuditLogResponse.From).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("api/v1/projects/{projectId}/audit-logs")]
|
||||
public async Task<ActionResult<IReadOnlyList<AuditLogResponse>>> ListByProject(int projectId, CancellationToken ct)
|
||||
{
|
||||
var project = await projectService.FindByIdAsync(projectId, ct);
|
||||
if (project is null) return NotFound();
|
||||
|
||||
var logs = await auditLogQueryService.ListByProjectAsync(projectId, ct);
|
||||
return Ok(logs.Select(AuditLogResponse.From).ToList());
|
||||
}
|
||||
}
|
||||
38
src/api/MicCheck.Api/Audit/AuditService.cs
Normal file
38
src/api/MicCheck.Api/Audit/AuditService.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
using MicCheck.Api.Data;
|
||||
|
||||
namespace MicCheck.Api.Audit;
|
||||
|
||||
public class AuditService(MicCheckDbContext db, IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
public virtual async Task LogAsync(
|
||||
string resourceType,
|
||||
string resourceId,
|
||||
string action,
|
||||
int organizationId,
|
||||
int? projectId = null,
|
||||
int? environmentId = null,
|
||||
string? changes = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
int? actorUserId = null;
|
||||
|
||||
var userIdClaim = httpContextAccessor.HttpContext?.User.FindFirst("UserId")?.Value;
|
||||
if (int.TryParse(userIdClaim, out var parsedId))
|
||||
actorUserId = parsedId;
|
||||
|
||||
db.AuditLogs.Add(new AuditLog
|
||||
{
|
||||
ResourceType = resourceType,
|
||||
ResourceId = resourceId,
|
||||
Action = action,
|
||||
OrganizationId = organizationId,
|
||||
ProjectId = projectId,
|
||||
EnvironmentId = environmentId,
|
||||
ActorUserId = actorUserId,
|
||||
Changes = changes,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
3
src/api/MicCheck.Api/Common/DomainException.cs
Normal file
3
src/api/MicCheck.Api/Common/DomainException.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace MicCheck.Api.Common;
|
||||
|
||||
public class DomainException(string message) : Exception(message);
|
||||
8
src/api/MicCheck.Api/Common/PaginatedResponse.cs
Normal file
8
src/api/MicCheck.Api/Common/PaginatedResponse.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace MicCheck.Api.Common;
|
||||
|
||||
public record PaginatedResponse<T>(
|
||||
int Count,
|
||||
string? Next,
|
||||
string? Previous,
|
||||
IReadOnlyList<T> Results
|
||||
);
|
||||
13
src/api/MicCheck.Api/Environments/CloneEnvironmentRequest.cs
Normal file
13
src/api/MicCheck.Api/Environments/CloneEnvironmentRequest.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace MicCheck.Api.Environments;
|
||||
|
||||
public record CloneEnvironmentRequest(string Name);
|
||||
|
||||
public class CloneEnvironmentRequestValidator : AbstractValidator<CloneEnvironmentRequest>
|
||||
{
|
||||
public CloneEnvironmentRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace MicCheck.Api.Environments;
|
||||
|
||||
public record CreateEnvironmentRequest(string Name, int ProjectId);
|
||||
|
||||
public class CreateEnvironmentRequestValidator : AbstractValidator<CreateEnvironmentRequest>
|
||||
{
|
||||
public CreateEnvironmentRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
|
||||
RuleFor(x => x.ProjectId).GreaterThan(0);
|
||||
}
|
||||
}
|
||||
15
src/api/MicCheck.Api/Environments/EnvironmentResponse.cs
Normal file
15
src/api/MicCheck.Api/Environments/EnvironmentResponse.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using AppEnvironment = MicCheck.Api.Environments.Environment;
|
||||
|
||||
namespace MicCheck.Api.Environments;
|
||||
|
||||
public record EnvironmentResponse(
|
||||
int Id,
|
||||
string Name,
|
||||
string ApiKey,
|
||||
int ProjectId,
|
||||
DateTimeOffset CreatedAt
|
||||
)
|
||||
{
|
||||
public static EnvironmentResponse From(AppEnvironment env) => new(
|
||||
env.Id, env.Name, env.ApiKey, env.ProjectId, env.CreatedAt);
|
||||
}
|
||||
135
src/api/MicCheck.Api/Environments/EnvironmentService.cs
Normal file
135
src/api/MicCheck.Api/Environments/EnvironmentService.cs
Normal file
@@ -0,0 +1,135 @@
|
||||
using MicCheck.Api.ApiKeys;
|
||||
using MicCheck.Api.Audit;
|
||||
using MicCheck.Api.Data;
|
||||
using MicCheck.Api.Features;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using AppEnvironment = MicCheck.Api.Environments.Environment;
|
||||
|
||||
namespace MicCheck.Api.Environments;
|
||||
|
||||
public class EnvironmentService(MicCheckDbContext db, AuditService auditService)
|
||||
{
|
||||
public async Task<IReadOnlyList<AppEnvironment>> ListByProjectAsync(int projectId, CancellationToken ct = default)
|
||||
{
|
||||
return await db.Environments
|
||||
.Where(e => e.ProjectId == projectId)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<AppEnvironment?> FindByApiKeyAsync(string apiKey, CancellationToken ct = default)
|
||||
{
|
||||
return await db.Environments.FirstOrDefaultAsync(e => e.ApiKey == apiKey, ct);
|
||||
}
|
||||
|
||||
public async Task<AppEnvironment> CreateAsync(int projectId, string name, CancellationToken ct = default)
|
||||
{
|
||||
var apiKey = ApiKeyHasher.GenerateKey();
|
||||
|
||||
var environment = new AppEnvironment
|
||||
{
|
||||
Name = name,
|
||||
ApiKey = apiKey,
|
||||
ProjectId = projectId,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
db.Environments.Add(environment);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var features = await db.Features
|
||||
.Where(f => f.ProjectId == projectId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var feature in features)
|
||||
{
|
||||
db.FeatureStates.Add(new FeatureState
|
||||
{
|
||||
FeatureId = feature.Id,
|
||||
EnvironmentId = environment.Id,
|
||||
Enabled = feature.DefaultEnabled,
|
||||
Value = feature.InitialValue,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
if (features.Count > 0)
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == projectId, ct);
|
||||
if (project is not null)
|
||||
await auditService.LogAsync("Environment", environment.Id.ToString(), "created",
|
||||
project.OrganizationId, projectId, environment.Id, ct: ct);
|
||||
|
||||
return environment;
|
||||
}
|
||||
|
||||
public async Task<AppEnvironment> UpdateAsync(string apiKey, string name, CancellationToken ct = default)
|
||||
{
|
||||
var environment = await db.Environments.FirstOrDefaultAsync(e => e.ApiKey == apiKey, ct)
|
||||
?? throw new KeyNotFoundException($"Environment with key '{apiKey}' not found.");
|
||||
|
||||
environment.Name = name;
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == environment.ProjectId, ct);
|
||||
if (project is not null)
|
||||
await auditService.LogAsync("Environment", environment.Id.ToString(), "updated",
|
||||
project.OrganizationId, environment.ProjectId, environment.Id, ct: ct);
|
||||
|
||||
return environment;
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string apiKey, CancellationToken ct = default)
|
||||
{
|
||||
var environment = await db.Environments.FirstOrDefaultAsync(e => e.ApiKey == apiKey, ct);
|
||||
if (environment is null) return;
|
||||
|
||||
db.Environments.Remove(environment);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<AppEnvironment> CloneAsync(string sourceApiKey, string newName, CancellationToken ct = default)
|
||||
{
|
||||
var source = await db.Environments.FirstOrDefaultAsync(e => e.ApiKey == sourceApiKey, ct)
|
||||
?? throw new KeyNotFoundException($"Source environment with key '{sourceApiKey}' not found.");
|
||||
|
||||
var newApiKey = ApiKeyHasher.GenerateKey();
|
||||
|
||||
var cloned = new AppEnvironment
|
||||
{
|
||||
Name = newName,
|
||||
ApiKey = newApiKey,
|
||||
ProjectId = source.ProjectId,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
db.Environments.Add(cloned);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var sourceStates = await db.FeatureStates
|
||||
.Where(fs => fs.EnvironmentId == source.Id && fs.IdentityId == null && fs.FeatureSegmentId == null)
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var state in sourceStates)
|
||||
{
|
||||
db.FeatureStates.Add(new FeatureState
|
||||
{
|
||||
FeatureId = state.FeatureId,
|
||||
EnvironmentId = cloned.Id,
|
||||
Enabled = state.Enabled,
|
||||
Value = state.Value,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
if (sourceStates.Count > 0)
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == source.ProjectId, ct);
|
||||
if (project is not null)
|
||||
await auditService.LogAsync("Environment", cloned.Id.ToString(), "cloned",
|
||||
project.OrganizationId, source.ProjectId, cloned.Id, ct: ct);
|
||||
|
||||
return cloned;
|
||||
}
|
||||
}
|
||||
291
src/api/MicCheck.Api/Environments/EnvironmentsController.cs
Normal file
291
src/api/MicCheck.Api/Environments/EnvironmentsController.cs
Normal file
@@ -0,0 +1,291 @@
|
||||
using MicCheck.Api.Authorization;
|
||||
using MicCheck.Api.Common;
|
||||
using MicCheck.Api.Webhooks;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
|
||||
namespace MicCheck.Api.Environments;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/environments")]
|
||||
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
|
||||
[EnableRateLimiting("AdminApi")]
|
||||
public class EnvironmentsController(EnvironmentService environmentService, WebhookService webhookService) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<PaginatedResponse<EnvironmentResponse>>> List(
|
||||
[FromQuery] int projectId,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||
var all = await environmentService.ListByProjectAsync(projectId, ct);
|
||||
var paged = all.Skip((page - 1) * pageSize).Take(pageSize).Select(EnvironmentResponse.From).ToList();
|
||||
return Ok(new PaginatedResponse<EnvironmentResponse>(all.Count, null, null, paged));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<EnvironmentResponse>> Create(CreateEnvironmentRequest request, CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.CreateAsync(request.ProjectId, request.Name, ct);
|
||||
return CreatedAtAction(nameof(GetByApiKey), new { apiKey = environment.ApiKey }, EnvironmentResponse.From(environment));
|
||||
}
|
||||
|
||||
[HttpGet("{apiKey}")]
|
||||
public async Task<ActionResult<EnvironmentResponse>> GetByApiKey(string apiKey, CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
return Ok(EnvironmentResponse.From(environment));
|
||||
}
|
||||
|
||||
[HttpPut("{apiKey}")]
|
||||
public async Task<ActionResult<EnvironmentResponse>> Update(string apiKey, UpdateEnvironmentRequest request, CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var updated = await environmentService.UpdateAsync(apiKey, request.Name, ct);
|
||||
return Ok(EnvironmentResponse.From(updated));
|
||||
}
|
||||
|
||||
[HttpDelete("{apiKey}")]
|
||||
public async Task<IActionResult> Delete(string apiKey, CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
await environmentService.DeleteAsync(apiKey, ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("{apiKey}/clone")]
|
||||
public async Task<ActionResult<EnvironmentResponse>> Clone(string apiKey, CloneEnvironmentRequest request, CancellationToken ct)
|
||||
{
|
||||
var source = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (source is null) return NotFound();
|
||||
|
||||
var cloned = await environmentService.CloneAsync(apiKey, request.Name, ct);
|
||||
return CreatedAtAction(nameof(GetByApiKey), new { apiKey = cloned.ApiKey }, EnvironmentResponse.From(cloned));
|
||||
}
|
||||
|
||||
[HttpGet("{apiKey}/webhooks")]
|
||||
public async Task<ActionResult<IReadOnlyList<WebhookResponse>>> ListWebhooks(string apiKey, CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var webhooks = await webhookService.ListByEnvironmentAsync(environment.Id, ct);
|
||||
return Ok(webhooks.Select(WebhookResponse.From).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("{apiKey}/webhooks")]
|
||||
public async Task<ActionResult<WebhookResponse>> CreateWebhook(
|
||||
string apiKey, CreateWebhookRequest request, CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var webhook = await webhookService.CreateAsync(environment.Id, request.Url, request.Secret, request.Enabled, ct);
|
||||
return CreatedAtAction(nameof(GetByApiKey), new { apiKey }, WebhookResponse.From(webhook));
|
||||
}
|
||||
|
||||
[HttpPut("{apiKey}/webhooks/{id}")]
|
||||
public async Task<ActionResult<WebhookResponse>> UpdateWebhook(
|
||||
string apiKey, int id, CreateWebhookRequest request, CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var webhook = await webhookService.FindByIdAsync(id, ct);
|
||||
if (webhook is null || webhook.EnvironmentId != environment.Id) return NotFound();
|
||||
|
||||
var updated = await webhookService.UpdateAsync(id, request.Url, request.Secret, request.Enabled, ct);
|
||||
return Ok(WebhookResponse.From(updated));
|
||||
}
|
||||
|
||||
[HttpDelete("{apiKey}/webhooks/{id}")]
|
||||
public async Task<IActionResult> DeleteWebhook(string apiKey, int id, CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var webhook = await webhookService.FindByIdAsync(id, ct);
|
||||
if (webhook is null || webhook.EnvironmentId != environment.Id) return NotFound();
|
||||
|
||||
await webhookService.DeleteAsync(id, ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("{apiKey}/audit-logs")]
|
||||
public async Task<ActionResult<IReadOnlyList<Audit.AuditLogResponse>>> ListAuditLogs(
|
||||
string apiKey,
|
||||
[FromServices] Audit.AuditLogQueryService auditLogQueryService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var logs = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, ct);
|
||||
return Ok(logs.Select(Audit.AuditLogResponse.From).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("{apiKey}/identities")]
|
||||
public async Task<ActionResult<PaginatedResponse<Identities.AdminIdentityResponse>>> ListIdentities(
|
||||
string apiKey,
|
||||
[FromServices] Identities.AdminIdentityService adminIdentityService,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||
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<Identities.AdminIdentityResponse>(total, null, null, results));
|
||||
}
|
||||
|
||||
[HttpGet("{apiKey}/identities/{id}")]
|
||||
public async Task<ActionResult<Identities.AdminIdentityResponse>> GetIdentity(
|
||||
string apiKey, int id,
|
||||
[FromServices] Identities.AdminIdentityService adminIdentityService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var identity = await adminIdentityService.FindByIdAsync(id, environment.Id, ct);
|
||||
if (identity is null) return NotFound();
|
||||
return Ok(Identities.AdminIdentityResponse.From(identity));
|
||||
}
|
||||
|
||||
[HttpDelete("{apiKey}/identities/{id}")]
|
||||
public async Task<IActionResult> DeleteIdentity(
|
||||
string apiKey, int id,
|
||||
[FromServices] Identities.AdminIdentityService adminIdentityService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var identity = await adminIdentityService.FindByIdAsync(id, environment.Id, ct);
|
||||
if (identity is null) return NotFound();
|
||||
|
||||
await adminIdentityService.DeleteAsync(id, ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("{apiKey}/identities/{id}/featurestates")]
|
||||
public async Task<ActionResult<IReadOnlyList<Features.FeatureStateResponse>>> GetIdentityFeatureStates(
|
||||
string apiKey, int id,
|
||||
[FromServices] Identities.AdminIdentityService adminIdentityService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var identity = await adminIdentityService.FindByIdAsync(id, environment.Id, ct);
|
||||
if (identity is null) return NotFound();
|
||||
|
||||
var states = await adminIdentityService.GetFeatureStatesAsync(id, environment.Id, ct);
|
||||
return Ok(states.Select(Features.FeatureStateResponse.From).ToList());
|
||||
}
|
||||
|
||||
[HttpPut("{apiKey}/identities/{id}/featurestates/{featureId}")]
|
||||
public async Task<ActionResult<Features.FeatureStateResponse>> SetIdentityFeatureState(
|
||||
string apiKey, int id, int featureId,
|
||||
Features.UpdateFeatureStateRequest request,
|
||||
[FromServices] Identities.AdminIdentityService adminIdentityService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var identity = await adminIdentityService.FindByIdAsync(id, environment.Id, ct);
|
||||
if (identity is null) return NotFound();
|
||||
|
||||
var state = await adminIdentityService.SetFeatureStateAsync(id, environment.Id, featureId, request.Enabled, request.Value, ct);
|
||||
return Ok(Features.FeatureStateResponse.From(state));
|
||||
}
|
||||
|
||||
[HttpDelete("{apiKey}/identities/{id}/featurestates/{featureId}")]
|
||||
public async Task<IActionResult> DeleteIdentityFeatureState(
|
||||
string apiKey, int id, int featureId,
|
||||
[FromServices] Identities.AdminIdentityService adminIdentityService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var identity = await adminIdentityService.FindByIdAsync(id, environment.Id, ct);
|
||||
if (identity is null) return NotFound();
|
||||
|
||||
await adminIdentityService.DeleteFeatureStateAsync(id, environment.Id, featureId, ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("{apiKey}/featurestates")]
|
||||
public async Task<ActionResult<IReadOnlyList<Features.FeatureStateResponse>>> ListFeatureStates(
|
||||
string apiKey,
|
||||
[FromServices] Features.FeatureStateService featureStateService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var states = await featureStateService.ListByEnvironmentAsync(environment.Id, ct);
|
||||
return Ok(states.Select(Features.FeatureStateResponse.From).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("{apiKey}/featurestates/{id}")]
|
||||
public async Task<ActionResult<Features.FeatureStateResponse>> GetFeatureState(
|
||||
string apiKey, int id,
|
||||
[FromServices] Features.FeatureStateService featureStateService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var state = await featureStateService.FindByIdAsync(id, environment.Id, ct);
|
||||
if (state is null) return NotFound();
|
||||
return Ok(Features.FeatureStateResponse.From(state));
|
||||
}
|
||||
|
||||
[HttpPut("{apiKey}/featurestates/{id}")]
|
||||
public async Task<ActionResult<Features.FeatureStateResponse>> UpdateFeatureState(
|
||||
string apiKey, int id,
|
||||
Features.UpdateFeatureStateRequest request,
|
||||
[FromServices] Features.FeatureStateService featureStateService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var state = await featureStateService.FindByIdAsync(id, environment.Id, ct);
|
||||
if (state is null) return NotFound();
|
||||
|
||||
var updated = await featureStateService.UpdateAsync(id, request.Enabled, request.Value, ct);
|
||||
return Ok(Features.FeatureStateResponse.From(updated));
|
||||
}
|
||||
|
||||
[HttpPatch("{apiKey}/featurestates/{id}")]
|
||||
public async Task<ActionResult<Features.FeatureStateResponse>> PatchFeatureState(
|
||||
string apiKey, int id,
|
||||
Features.PatchFeatureStateRequest request,
|
||||
[FromServices] Features.FeatureStateService featureStateService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var state = await featureStateService.FindByIdAsync(id, environment.Id, ct);
|
||||
if (state is null) return NotFound();
|
||||
|
||||
var updated = await featureStateService.PatchAsync(id, request.Enabled, request.Value, ct);
|
||||
return Ok(Features.FeatureStateResponse.From(updated));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace MicCheck.Api.Environments;
|
||||
|
||||
public record UpdateEnvironmentRequest(string Name);
|
||||
|
||||
public class UpdateEnvironmentRequestValidator : AbstractValidator<UpdateEnvironmentRequest>
|
||||
{
|
||||
public UpdateEnvironmentRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
|
||||
}
|
||||
}
|
||||
26
src/api/MicCheck.Api/Features/CreateFeatureRequest.cs
Normal file
26
src/api/MicCheck.Api/Features/CreateFeatureRequest.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace MicCheck.Api.Features;
|
||||
|
||||
public record CreateFeatureRequest(
|
||||
string Name,
|
||||
FeatureType Type,
|
||||
string? InitialValue,
|
||||
string? Description
|
||||
);
|
||||
|
||||
public class CreateFeatureRequestValidator : AbstractValidator<CreateFeatureRequest>
|
||||
{
|
||||
public CreateFeatureRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Name)
|
||||
.NotEmpty()
|
||||
.MaximumLength(150)
|
||||
.Matches("^[a-zA-Z0-9_-]+$")
|
||||
.WithMessage("Name may only contain letters, digits, underscores, and hyphens.");
|
||||
|
||||
RuleFor(x => x.InitialValue)
|
||||
.MaximumLength(20_000)
|
||||
.When(x => x.InitialValue is not null);
|
||||
}
|
||||
}
|
||||
16
src/api/MicCheck.Api/Features/CreateTagRequest.cs
Normal file
16
src/api/MicCheck.Api/Features/CreateTagRequest.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace MicCheck.Api.Features;
|
||||
|
||||
public record CreateTagRequest(string Label, string Color);
|
||||
|
||||
public class CreateTagRequestValidator : AbstractValidator<CreateTagRequest>
|
||||
{
|
||||
public CreateTagRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Label).NotEmpty().MaximumLength(100);
|
||||
RuleFor(x => x.Color).NotEmpty().MaximumLength(20)
|
||||
.Matches("^#[0-9A-Fa-f]{3,6}$")
|
||||
.WithMessage("Color must be a valid hex color (e.g. #FF0000).");
|
||||
}
|
||||
}
|
||||
23
src/api/MicCheck.Api/Features/FeatureResponse.cs
Normal file
23
src/api/MicCheck.Api/Features/FeatureResponse.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
namespace MicCheck.Api.Features;
|
||||
|
||||
public record FeatureResponse(
|
||||
int Id,
|
||||
string Name,
|
||||
string Type,
|
||||
string? InitialValue,
|
||||
string? Description,
|
||||
bool DefaultEnabled,
|
||||
int ProjectId,
|
||||
DateTimeOffset CreatedAt
|
||||
)
|
||||
{
|
||||
public static FeatureResponse From(Feature feature) => new(
|
||||
feature.Id,
|
||||
feature.Name,
|
||||
feature.Type.ToString().ToUpperInvariant(),
|
||||
feature.InitialValue,
|
||||
feature.Description,
|
||||
feature.DefaultEnabled,
|
||||
feature.ProjectId,
|
||||
feature.CreatedAt);
|
||||
}
|
||||
102
src/api/MicCheck.Api/Features/FeatureService.cs
Normal file
102
src/api/MicCheck.Api/Features/FeatureService.cs
Normal file
@@ -0,0 +1,102 @@
|
||||
using MicCheck.Api.Audit;
|
||||
using MicCheck.Api.Common;
|
||||
using MicCheck.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MicCheck.Api.Features;
|
||||
|
||||
public class FeatureService(MicCheckDbContext db, AuditService auditService)
|
||||
{
|
||||
private const int MaxFeaturesPerProject = 400;
|
||||
|
||||
public async Task<IReadOnlyList<Feature>> ListByProjectAsync(int projectId, CancellationToken ct = default)
|
||||
{
|
||||
return await db.Features
|
||||
.Where(f => f.ProjectId == projectId)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<Feature?> FindByIdAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
return await db.Features.FirstOrDefaultAsync(f => f.Id == id, ct);
|
||||
}
|
||||
|
||||
public async Task<Feature> CreateAsync(
|
||||
int projectId,
|
||||
string name,
|
||||
FeatureType type,
|
||||
string? initialValue,
|
||||
string? description,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var count = await db.Features.CountAsync(f => f.ProjectId == projectId, ct);
|
||||
if (count >= MaxFeaturesPerProject)
|
||||
throw new DomainException($"Project has reached the maximum of {MaxFeaturesPerProject} features.");
|
||||
|
||||
var feature = new Feature
|
||||
{
|
||||
Name = name,
|
||||
Type = type,
|
||||
InitialValue = initialValue,
|
||||
Description = description,
|
||||
ProjectId = projectId,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
db.Features.Add(feature);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var environments = await db.Environments
|
||||
.Where(e => e.ProjectId == projectId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var env in environments)
|
||||
{
|
||||
db.FeatureStates.Add(new FeatureState
|
||||
{
|
||||
FeatureId = feature.Id,
|
||||
EnvironmentId = env.Id,
|
||||
Enabled = feature.DefaultEnabled,
|
||||
Value = initialValue,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
if (environments.Count > 0)
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == projectId, ct);
|
||||
if (project is not null)
|
||||
await auditService.LogAsync("Feature", feature.Id.ToString(), "created",
|
||||
project.OrganizationId, projectId, ct: ct);
|
||||
|
||||
return feature;
|
||||
}
|
||||
|
||||
public async Task<Feature> UpdateAsync(
|
||||
int id, string name, string? description, CancellationToken ct = default)
|
||||
{
|
||||
var feature = await db.Features.FirstOrDefaultAsync(f => f.Id == id, ct)
|
||||
?? throw new KeyNotFoundException($"Feature {id} not found.");
|
||||
|
||||
feature.Name = name;
|
||||
feature.Description = description;
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == feature.ProjectId, ct);
|
||||
if (project is not null)
|
||||
await auditService.LogAsync("Feature", feature.Id.ToString(), "updated",
|
||||
project.OrganizationId, feature.ProjectId, ct: ct);
|
||||
|
||||
return feature;
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
var feature = await db.Features.FirstOrDefaultAsync(f => f.Id == id, ct);
|
||||
if (feature is null) return;
|
||||
|
||||
db.Features.Remove(feature);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
25
src/api/MicCheck.Api/Features/FeatureStateResponse.cs
Normal file
25
src/api/MicCheck.Api/Features/FeatureStateResponse.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
namespace MicCheck.Api.Features;
|
||||
|
||||
public record FeatureStateResponse(
|
||||
int Id,
|
||||
int FeatureId,
|
||||
int EnvironmentId,
|
||||
int? IdentityId,
|
||||
int? FeatureSegmentId,
|
||||
bool Enabled,
|
||||
string? Value,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt
|
||||
)
|
||||
{
|
||||
public static FeatureStateResponse From(FeatureState state) => new(
|
||||
state.Id,
|
||||
state.FeatureId,
|
||||
state.EnvironmentId,
|
||||
state.IdentityId,
|
||||
state.FeatureSegmentId,
|
||||
state.Enabled,
|
||||
state.Value,
|
||||
state.CreatedAt,
|
||||
state.UpdatedAt);
|
||||
}
|
||||
46
src/api/MicCheck.Api/Features/FeatureStateService.cs
Normal file
46
src/api/MicCheck.Api/Features/FeatureStateService.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
using MicCheck.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MicCheck.Api.Features;
|
||||
|
||||
public class FeatureStateService(MicCheckDbContext db)
|
||||
{
|
||||
public async Task<IReadOnlyList<FeatureState>> ListByEnvironmentAsync(int environmentId, CancellationToken ct = default)
|
||||
{
|
||||
return await db.FeatureStates
|
||||
.Where(fs => fs.EnvironmentId == environmentId && fs.IdentityId == null && fs.FeatureSegmentId == null)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<FeatureState?> FindByIdAsync(int id, int environmentId, CancellationToken ct = default)
|
||||
{
|
||||
return await db.FeatureStates
|
||||
.FirstOrDefaultAsync(fs => fs.Id == id && fs.EnvironmentId == environmentId, ct);
|
||||
}
|
||||
|
||||
public async Task<FeatureState> UpdateAsync(int id, bool enabled, string? value, CancellationToken ct = default)
|
||||
{
|
||||
var state = await db.FeatureStates.FirstOrDefaultAsync(fs => fs.Id == id, ct)
|
||||
?? throw new KeyNotFoundException($"FeatureState {id} not found.");
|
||||
|
||||
state.Enabled = enabled;
|
||||
state.Value = value;
|
||||
state.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
state.Version++;
|
||||
await db.SaveChangesAsync(ct);
|
||||
return state;
|
||||
}
|
||||
|
||||
public async Task<FeatureState> PatchAsync(int id, bool? enabled, string? value, CancellationToken ct = default)
|
||||
{
|
||||
var state = await db.FeatureStates.FirstOrDefaultAsync(fs => fs.Id == id, ct)
|
||||
?? throw new KeyNotFoundException($"FeatureState {id} not found.");
|
||||
|
||||
if (enabled.HasValue) state.Enabled = enabled.Value;
|
||||
if (value is not null) state.Value = value;
|
||||
state.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
state.Version++;
|
||||
await db.SaveChangesAsync(ct);
|
||||
return state;
|
||||
}
|
||||
}
|
||||
88
src/api/MicCheck.Api/Features/FeaturesController.cs
Normal file
88
src/api/MicCheck.Api/Features/FeaturesController.cs
Normal file
@@ -0,0 +1,88 @@
|
||||
using MicCheck.Api.Authorization;
|
||||
using MicCheck.Api.Common;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
|
||||
namespace MicCheck.Api.Features;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/projects/{projectId}/features")]
|
||||
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
|
||||
[EnableRateLimiting("AdminApi")]
|
||||
public class FeaturesController(FeatureService featureService) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<PaginatedResponse<FeatureResponse>>> List(
|
||||
int projectId,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||
var all = await featureService.ListByProjectAsync(projectId, ct);
|
||||
var paged = all.Skip((page - 1) * pageSize).Take(pageSize).Select(FeatureResponse.From).ToList();
|
||||
return Ok(new PaginatedResponse<FeatureResponse>(all.Count, null, null, paged));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<FeatureResponse>> Create(
|
||||
int projectId, CreateFeatureRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var feature = await featureService.CreateAsync(
|
||||
projectId, request.Name, request.Type, request.InitialValue, request.Description, ct);
|
||||
|
||||
return CreatedAtAction(nameof(GetById), new { projectId, id = feature.Id }, FeatureResponse.From(feature));
|
||||
}
|
||||
catch (DomainException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<FeatureResponse>> GetById(int projectId, int id, CancellationToken ct)
|
||||
{
|
||||
var feature = await featureService.FindByIdAsync(id, ct);
|
||||
if (feature is null || feature.ProjectId != projectId) return NotFound();
|
||||
return Ok(FeatureResponse.From(feature));
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<ActionResult<FeatureResponse>> Update(
|
||||
int projectId, int id, UpdateFeatureRequest request, CancellationToken ct)
|
||||
{
|
||||
var feature = await featureService.FindByIdAsync(id, ct);
|
||||
if (feature is null || feature.ProjectId != projectId) return NotFound();
|
||||
|
||||
var updated = await featureService.UpdateAsync(id, request.Name, request.Description, ct);
|
||||
return Ok(FeatureResponse.From(updated));
|
||||
}
|
||||
|
||||
[HttpPatch("{id}")]
|
||||
public async Task<ActionResult<FeatureResponse>> Patch(
|
||||
int projectId, int id, PatchFeatureRequest request, CancellationToken ct)
|
||||
{
|
||||
var feature = await featureService.FindByIdAsync(id, ct);
|
||||
if (feature is null || feature.ProjectId != projectId) return NotFound();
|
||||
|
||||
if (request.Name is not null) feature.Name = request.Name;
|
||||
if (request.Description is not null) feature.Description = request.Description;
|
||||
if (request.DefaultEnabled.HasValue) feature.DefaultEnabled = request.DefaultEnabled.Value;
|
||||
|
||||
var updated = await featureService.UpdateAsync(id, feature.Name, feature.Description, ct);
|
||||
return Ok(FeatureResponse.From(updated));
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> Delete(int projectId, int id, CancellationToken ct)
|
||||
{
|
||||
var feature = await featureService.FindByIdAsync(id, ct);
|
||||
if (feature is null || feature.ProjectId != projectId) return NotFound();
|
||||
|
||||
await featureService.DeleteAsync(id, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
7
src/api/MicCheck.Api/Features/PatchFeatureRequest.cs
Normal file
7
src/api/MicCheck.Api/Features/PatchFeatureRequest.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace MicCheck.Api.Features;
|
||||
|
||||
public record PatchFeatureRequest(
|
||||
string? Name,
|
||||
string? Description,
|
||||
bool? DefaultEnabled
|
||||
);
|
||||
6
src/api/MicCheck.Api/Features/TagResponse.cs
Normal file
6
src/api/MicCheck.Api/Features/TagResponse.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace MicCheck.Api.Features;
|
||||
|
||||
public record TagResponse(int Id, string Label, string Color, int ProjectId)
|
||||
{
|
||||
public static TagResponse From(Tag tag) => new(tag.Id, tag.Label, tag.Color, tag.ProjectId);
|
||||
}
|
||||
33
src/api/MicCheck.Api/Features/TagService.cs
Normal file
33
src/api/MicCheck.Api/Features/TagService.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using MicCheck.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MicCheck.Api.Features;
|
||||
|
||||
public class TagService(MicCheckDbContext db)
|
||||
{
|
||||
public async Task<IReadOnlyList<Tag>> ListByProjectAsync(int projectId, CancellationToken ct = default)
|
||||
{
|
||||
return await db.Tags.Where(t => t.ProjectId == projectId).ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<Tag> CreateAsync(int projectId, string label, string color, CancellationToken ct = default)
|
||||
{
|
||||
var tag = new Tag { Label = label, Color = color, ProjectId = projectId };
|
||||
db.Tags.Add(tag);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return tag;
|
||||
}
|
||||
|
||||
public async Task<Tag?> FindByIdAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
return await db.Tags.FirstOrDefaultAsync(t => t.Id == id, ct);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
var tag = await db.Tags.FirstOrDefaultAsync(t => t.Id == id, ct);
|
||||
if (tag is null) return;
|
||||
db.Tags.Remove(tag);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
37
src/api/MicCheck.Api/Features/TagsController.cs
Normal file
37
src/api/MicCheck.Api/Features/TagsController.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
using MicCheck.Api.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
|
||||
namespace MicCheck.Api.Features;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/projects/{projectId}/tags")]
|
||||
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
|
||||
[EnableRateLimiting("AdminApi")]
|
||||
public class TagsController(TagService tagService) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
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]
|
||||
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("{id}")]
|
||||
public async Task<IActionResult> Delete(int projectId, int id, CancellationToken ct)
|
||||
{
|
||||
var tag = await tagService.FindByIdAsync(id, ct);
|
||||
if (tag is null || tag.ProjectId != projectId) return NotFound();
|
||||
|
||||
await tagService.DeleteAsync(id, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
20
src/api/MicCheck.Api/Features/UpdateFeatureRequest.cs
Normal file
20
src/api/MicCheck.Api/Features/UpdateFeatureRequest.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace MicCheck.Api.Features;
|
||||
|
||||
public record UpdateFeatureRequest(
|
||||
string Name,
|
||||
string? Description
|
||||
);
|
||||
|
||||
public class UpdateFeatureRequestValidator : AbstractValidator<UpdateFeatureRequest>
|
||||
{
|
||||
public UpdateFeatureRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Name)
|
||||
.NotEmpty()
|
||||
.MaximumLength(150)
|
||||
.Matches("^[a-zA-Z0-9_-]+$")
|
||||
.WithMessage("Name may only contain letters, digits, underscores, and hyphens.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace MicCheck.Api.Features;
|
||||
|
||||
public record UpdateFeatureStateRequest(bool Enabled, string? Value);
|
||||
|
||||
public record PatchFeatureStateRequest(bool? Enabled, string? Value);
|
||||
17
src/api/MicCheck.Api/Identities/AdminIdentityResponse.cs
Normal file
17
src/api/MicCheck.Api/Identities/AdminIdentityResponse.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace MicCheck.Api.Identities;
|
||||
|
||||
public record AdminIdentityResponse(
|
||||
int Id,
|
||||
string Identifier,
|
||||
int EnvironmentId,
|
||||
DateTimeOffset CreatedAt,
|
||||
IReadOnlyList<TraitResponse> Traits
|
||||
)
|
||||
{
|
||||
public static AdminIdentityResponse From(Identity identity) => new(
|
||||
identity.Id,
|
||||
identity.Identifier,
|
||||
identity.EnvironmentId,
|
||||
identity.CreatedAt,
|
||||
identity.Traits.Select(t => new TraitResponse(t.Key, t.Value)).ToList());
|
||||
}
|
||||
93
src/api/MicCheck.Api/Identities/AdminIdentityService.cs
Normal file
93
src/api/MicCheck.Api/Identities/AdminIdentityService.cs
Normal file
@@ -0,0 +1,93 @@
|
||||
using MicCheck.Api.Data;
|
||||
using MicCheck.Api.Features;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MicCheck.Api.Identities;
|
||||
|
||||
public class AdminIdentityService(MicCheckDbContext db)
|
||||
{
|
||||
public async Task<(int Total, IReadOnlyList<Identity> Items)> ListAsync(
|
||||
int environmentId, int page, int pageSize, CancellationToken ct = default)
|
||||
{
|
||||
var query = db.Identities
|
||||
.Include(i => i.Traits)
|
||||
.Where(i => i.EnvironmentId == environmentId);
|
||||
|
||||
var total = await query.CountAsync(ct);
|
||||
var items = await query
|
||||
.OrderBy(i => i.Id)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return (total, items);
|
||||
}
|
||||
|
||||
public async Task<Identity?> FindByIdAsync(int id, int environmentId, CancellationToken ct = default)
|
||||
{
|
||||
return await db.Identities
|
||||
.Include(i => i.Traits)
|
||||
.FirstOrDefaultAsync(i => i.Id == id && i.EnvironmentId == environmentId, ct);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
var identity = await db.Identities.FirstOrDefaultAsync(i => i.Id == id, ct);
|
||||
if (identity is null) return;
|
||||
|
||||
db.Identities.Remove(identity);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<FeatureState>> GetFeatureStatesAsync(
|
||||
int identityId, int environmentId, CancellationToken ct = default)
|
||||
{
|
||||
return await db.FeatureStates
|
||||
.Where(fs => fs.IdentityId == identityId && fs.EnvironmentId == environmentId)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<FeatureState> SetFeatureStateAsync(
|
||||
int identityId, int environmentId, int featureId, bool enabled, string? value, CancellationToken ct = default)
|
||||
{
|
||||
var state = await db.FeatureStates
|
||||
.FirstOrDefaultAsync(fs => fs.IdentityId == identityId && fs.EnvironmentId == environmentId && fs.FeatureId == featureId, ct);
|
||||
|
||||
if (state is not null)
|
||||
{
|
||||
state.Enabled = enabled;
|
||||
state.Value = value;
|
||||
state.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
state.Version++;
|
||||
}
|
||||
else
|
||||
{
|
||||
state = new FeatureState
|
||||
{
|
||||
FeatureId = featureId,
|
||||
EnvironmentId = environmentId,
|
||||
IdentityId = identityId,
|
||||
Enabled = enabled,
|
||||
Value = value,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
db.FeatureStates.Add(state);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return state;
|
||||
}
|
||||
|
||||
public async Task DeleteFeatureStateAsync(
|
||||
int identityId, int environmentId, int featureId, CancellationToken ct = default)
|
||||
{
|
||||
var state = await db.FeatureStates
|
||||
.FirstOrDefaultAsync(fs => fs.IdentityId == identityId && fs.EnvironmentId == environmentId && fs.FeatureId == featureId, ct);
|
||||
|
||||
if (state is null) return;
|
||||
|
||||
db.FeatureStates.Remove(state);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -23,8 +23,4 @@
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.16.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../infrastructure/MicCheck.Infrastructure/MicCheck.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace MicCheck.Api.Organizations;
|
||||
|
||||
public record CreateOrganizationRequest(string Name);
|
||||
|
||||
public class CreateOrganizationRequestValidator : AbstractValidator<CreateOrganizationRequest>
|
||||
{
|
||||
public CreateOrganizationRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
|
||||
}
|
||||
}
|
||||
15
src/api/MicCheck.Api/Organizations/InviteUserRequest.cs
Normal file
15
src/api/MicCheck.Api/Organizations/InviteUserRequest.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace MicCheck.Api.Organizations;
|
||||
|
||||
public record InviteUserRequest(int UserId, string Role);
|
||||
|
||||
public class InviteUserRequestValidator : AbstractValidator<InviteUserRequest>
|
||||
{
|
||||
public InviteUserRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.UserId).GreaterThan(0);
|
||||
RuleFor(x => x.Role).NotEmpty().Must(r => Enum.TryParse<OrganizationRole>(r, true, out _))
|
||||
.WithMessage("Role must be 'User' or 'Admin'.");
|
||||
}
|
||||
}
|
||||
20
src/api/MicCheck.Api/Organizations/OrganizationResponse.cs
Normal file
20
src/api/MicCheck.Api/Organizations/OrganizationResponse.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
namespace MicCheck.Api.Organizations;
|
||||
|
||||
public record OrganizationResponse(
|
||||
int Id,
|
||||
string Name,
|
||||
DateTimeOffset CreatedAt
|
||||
)
|
||||
{
|
||||
public static OrganizationResponse From(Organization org) => new(
|
||||
org.Id, org.Name, org.CreatedAt);
|
||||
}
|
||||
|
||||
public record OrganizationMemberResponse(
|
||||
int UserId,
|
||||
string Role
|
||||
)
|
||||
{
|
||||
public static OrganizationMemberResponse From(OrganizationUser member) => new(
|
||||
member.UserId, member.Role.ToString());
|
||||
}
|
||||
102
src/api/MicCheck.Api/Organizations/OrganizationService.cs
Normal file
102
src/api/MicCheck.Api/Organizations/OrganizationService.cs
Normal file
@@ -0,0 +1,102 @@
|
||||
using MicCheck.Api.Audit;
|
||||
using MicCheck.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MicCheck.Api.Organizations;
|
||||
|
||||
public class OrganizationService(MicCheckDbContext db, AuditService auditService)
|
||||
{
|
||||
public async Task<IReadOnlyList<Organization>> ListForUserAsync(int userId, CancellationToken ct = default)
|
||||
{
|
||||
return await db.Organizations
|
||||
.Where(o => o.Members.Any(m => m.UserId == userId))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<Organization?> FindByIdAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
return await db.Organizations.FirstOrDefaultAsync(o => o.Id == id, ct);
|
||||
}
|
||||
|
||||
public async Task<Organization> CreateAsync(string name, int creatorUserId, CancellationToken ct = default)
|
||||
{
|
||||
var org = new Organization
|
||||
{
|
||||
Name = name,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
org.Members.Add(new OrganizationUser
|
||||
{
|
||||
UserId = creatorUserId,
|
||||
Role = OrganizationRole.Admin
|
||||
});
|
||||
db.Organizations.Add(org);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
await auditService.LogAsync("Organization", org.Id.ToString(), "created", org.Id, ct: ct);
|
||||
|
||||
return org;
|
||||
}
|
||||
|
||||
public async Task<Organization> UpdateAsync(int id, string name, CancellationToken ct = default)
|
||||
{
|
||||
var org = await db.Organizations.FirstOrDefaultAsync(o => o.Id == id, ct)
|
||||
?? throw new KeyNotFoundException($"Organization {id} not found.");
|
||||
|
||||
org.Name = name;
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
await auditService.LogAsync("Organization", org.Id.ToString(), "updated", org.Id, ct: ct);
|
||||
|
||||
return org;
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
var org = await db.Organizations.FirstOrDefaultAsync(o => o.Id == id, ct);
|
||||
if (org is null) return;
|
||||
|
||||
db.Organizations.Remove(org);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<OrganizationUser>> ListMembersAsync(int organizationId, CancellationToken ct = default)
|
||||
{
|
||||
return await db.OrganizationUsers
|
||||
.Where(m => m.OrganizationId == organizationId)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task InviteUserAsync(int organizationId, int userId, OrganizationRole role, CancellationToken ct = default)
|
||||
{
|
||||
var existing = await db.OrganizationUsers
|
||||
.FirstOrDefaultAsync(m => m.OrganizationId == organizationId && m.UserId == userId, ct);
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
existing.Role = role;
|
||||
}
|
||||
else
|
||||
{
|
||||
db.OrganizationUsers.Add(new OrganizationUser
|
||||
{
|
||||
OrganizationId = organizationId,
|
||||
UserId = userId,
|
||||
Role = role
|
||||
});
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task RemoveMemberAsync(int organizationId, int userId, CancellationToken ct = default)
|
||||
{
|
||||
var member = await db.OrganizationUsers
|
||||
.FirstOrDefaultAsync(m => m.OrganizationId == organizationId && m.UserId == userId, ct);
|
||||
|
||||
if (member is null) return;
|
||||
|
||||
db.OrganizationUsers.Remove(member);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
106
src/api/MicCheck.Api/Organizations/OrganizationsController.cs
Normal file
106
src/api/MicCheck.Api/Organizations/OrganizationsController.cs
Normal file
@@ -0,0 +1,106 @@
|
||||
using MicCheck.Api.Authorization;
|
||||
using MicCheck.Api.Common;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
|
||||
namespace MicCheck.Api.Organizations;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/organisations")]
|
||||
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
|
||||
[EnableRateLimiting("AdminApi")]
|
||||
public class OrganizationsController(OrganizationService organizationService) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<PaginatedResponse<OrganizationResponse>>> List(
|
||||
[FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken ct = default)
|
||||
{
|
||||
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||
var userId = GetCurrentUserId();
|
||||
if (userId is null) return Unauthorized();
|
||||
|
||||
var all = await organizationService.ListForUserAsync(userId.Value, ct);
|
||||
var paged = all.Skip((page - 1) * pageSize).Take(pageSize).Select(OrganizationResponse.From).ToList();
|
||||
|
||||
return Ok(new PaginatedResponse<OrganizationResponse>(all.Count, null, null, paged));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<OrganizationResponse>> Create(
|
||||
CreateOrganizationRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = GetCurrentUserId();
|
||||
if (userId is null) return Unauthorized();
|
||||
|
||||
var org = await organizationService.CreateAsync(request.Name, userId.Value, ct);
|
||||
return CreatedAtAction(nameof(GetById), new { id = org.Id }, OrganizationResponse.From(org));
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<OrganizationResponse>> GetById(int id, CancellationToken ct)
|
||||
{
|
||||
var org = await organizationService.FindByIdAsync(id, ct);
|
||||
if (org is null) return NotFound();
|
||||
return Ok(OrganizationResponse.From(org));
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<ActionResult<OrganizationResponse>> Update(
|
||||
int id, UpdateOrganizationRequest request, CancellationToken ct)
|
||||
{
|
||||
var org = await organizationService.FindByIdAsync(id, ct);
|
||||
if (org is null) return NotFound();
|
||||
|
||||
var updated = await organizationService.UpdateAsync(id, request.Name, ct);
|
||||
return Ok(OrganizationResponse.From(updated));
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> Delete(int id, CancellationToken ct)
|
||||
{
|
||||
var org = await organizationService.FindByIdAsync(id, ct);
|
||||
if (org is null) return NotFound();
|
||||
|
||||
await organizationService.DeleteAsync(id, ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("{id}/users")]
|
||||
public async Task<ActionResult<IReadOnlyList<OrganizationMemberResponse>>> ListUsers(
|
||||
int id, CancellationToken ct)
|
||||
{
|
||||
var org = await organizationService.FindByIdAsync(id, ct);
|
||||
if (org is null) return NotFound();
|
||||
|
||||
var members = await organizationService.ListMembersAsync(id, ct);
|
||||
return Ok(members.Select(OrganizationMemberResponse.From).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("{id}/users/invite")]
|
||||
public async Task<IActionResult> InviteUser(int id, InviteUserRequest request, CancellationToken ct)
|
||||
{
|
||||
var org = await organizationService.FindByIdAsync(id, ct);
|
||||
if (org is null) return NotFound();
|
||||
|
||||
var role = Enum.Parse<OrganizationRole>(request.Role, ignoreCase: true);
|
||||
await organizationService.InviteUserAsync(id, request.UserId, role, ct);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[HttpDelete("{id}/users/{userId}")]
|
||||
public async Task<IActionResult> RemoveUser(int id, int userId, CancellationToken ct)
|
||||
{
|
||||
var org = await organizationService.FindByIdAsync(id, ct);
|
||||
if (org is null) return NotFound();
|
||||
|
||||
await organizationService.RemoveMemberAsync(id, userId, ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private int? GetCurrentUserId()
|
||||
{
|
||||
var claim = User.FindFirst("UserId")?.Value;
|
||||
return int.TryParse(claim, out var id) ? id : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace MicCheck.Api.Organizations;
|
||||
|
||||
public record UpdateOrganizationRequest(string Name);
|
||||
|
||||
public class UpdateOrganizationRequestValidator : AbstractValidator<UpdateOrganizationRequest>
|
||||
{
|
||||
public UpdateOrganizationRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using System.Threading.RateLimiting;
|
||||
using FluentValidation;
|
||||
using FluentValidation.AspNetCore;
|
||||
using MicCheck.Api.ApiKeys;
|
||||
using MicCheck.Api.Audit;
|
||||
using MicCheck.Api.Auth;
|
||||
using MicCheck.Api.Authentication;
|
||||
using MicCheck.Api.Authorization;
|
||||
@@ -10,12 +11,16 @@ using MicCheck.Api.Data;
|
||||
using MicCheck.Api.Environments;
|
||||
using MicCheck.Api.Features;
|
||||
using MicCheck.Api.Identities;
|
||||
using MicCheck.Api.Organizations;
|
||||
using MicCheck.Api.Projects;
|
||||
using MicCheck.Api.Segments;
|
||||
using MicCheck.Api.Users;
|
||||
using MicCheck.Api.Webhooks;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
@@ -36,7 +41,10 @@ try
|
||||
.WriteTo.Console());
|
||||
|
||||
builder.Services.AddOpenApi();
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddControllers()
|
||||
.AddJsonOptions(options =>
|
||||
options.JsonSerializerOptions.Converters.Add(
|
||||
new System.Text.Json.Serialization.JsonStringEnumConverter()));
|
||||
|
||||
builder.Services.AddAuthentication()
|
||||
.AddScheme<AuthenticationSchemeOptions, EnvironmentKeyAuthenticationHandler>(
|
||||
@@ -86,6 +94,19 @@ try
|
||||
builder.Services.AddFluentValidationAutoValidation();
|
||||
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
|
||||
|
||||
builder.Services.Configure<ApiBehaviorOptions>(options =>
|
||||
{
|
||||
options.InvalidModelStateResponseFactory = context =>
|
||||
{
|
||||
var errors = context.ModelState
|
||||
.Where(e => e.Value?.Errors.Count > 0)
|
||||
.ToDictionary(
|
||||
kvp => kvp.Key,
|
||||
kvp => kvp.Value!.Errors.Select(e => e.ErrorMessage).ToArray());
|
||||
return new UnprocessableEntityObjectResult(new { errors });
|
||||
};
|
||||
});
|
||||
|
||||
builder.Services.AddMemoryCache();
|
||||
|
||||
builder.Services.AddScoped<ITokenService, TokenService>();
|
||||
@@ -99,6 +120,17 @@ try
|
||||
builder.Services.AddScoped<EnvironmentDocumentService>();
|
||||
builder.Services.AddSingleton<SegmentEvaluator>();
|
||||
builder.Services.AddSingleton<FlagCache>();
|
||||
builder.Services.AddScoped<AuditService>();
|
||||
builder.Services.AddScoped<AuditLogQueryService>();
|
||||
builder.Services.AddScoped<OrganizationService>();
|
||||
builder.Services.AddScoped<ProjectService>();
|
||||
builder.Services.AddScoped<EnvironmentService>();
|
||||
builder.Services.AddScoped<FeatureService>();
|
||||
builder.Services.AddScoped<FeatureStateService>();
|
||||
builder.Services.AddScoped<SegmentService>();
|
||||
builder.Services.AddScoped<TagService>();
|
||||
builder.Services.AddScoped<WebhookService>();
|
||||
builder.Services.AddScoped<AdminIdentityService>();
|
||||
|
||||
var connectionString = System.Environment.GetEnvironmentVariable("DATABASE_URL") is { } databaseUrl
|
||||
? DatabaseUrlParser.ToNpgsqlConnectionString(databaseUrl)
|
||||
@@ -138,5 +170,3 @@ finally
|
||||
{
|
||||
Log.CloseAndFlush();
|
||||
}
|
||||
|
||||
public partial class Program { }
|
||||
|
||||
14
src/api/MicCheck.Api/Projects/CreateProjectRequest.cs
Normal file
14
src/api/MicCheck.Api/Projects/CreateProjectRequest.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace MicCheck.Api.Projects;
|
||||
|
||||
public record CreateProjectRequest(string Name, int OrganizationId);
|
||||
|
||||
public class CreateProjectRequestValidator : AbstractValidator<CreateProjectRequest>
|
||||
{
|
||||
public CreateProjectRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
|
||||
RuleFor(x => x.OrganizationId).GreaterThan(0);
|
||||
}
|
||||
}
|
||||
27
src/api/MicCheck.Api/Projects/ProjectResponse.cs
Normal file
27
src/api/MicCheck.Api/Projects/ProjectResponse.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using MicCheck.Api.Authorization;
|
||||
|
||||
namespace MicCheck.Api.Projects;
|
||||
|
||||
public record ProjectResponse(
|
||||
int Id,
|
||||
string Name,
|
||||
int OrganizationId,
|
||||
bool HideDisabledFlags,
|
||||
DateTimeOffset CreatedAt
|
||||
)
|
||||
{
|
||||
public static ProjectResponse From(Project project) => new(
|
||||
project.Id, project.Name, project.OrganizationId, project.HideDisabledFlags, project.CreatedAt);
|
||||
}
|
||||
|
||||
public record UserPermissionResponse(
|
||||
int UserId,
|
||||
int ProjectId,
|
||||
bool IsAdmin,
|
||||
IReadOnlyList<string> Permissions
|
||||
)
|
||||
{
|
||||
public static UserPermissionResponse From(UserProjectPermission p) => new(
|
||||
p.UserId, p.ProjectId, p.IsAdmin,
|
||||
p.Permissions.Select(x => x.ToString()).ToList());
|
||||
}
|
||||
105
src/api/MicCheck.Api/Projects/ProjectService.cs
Normal file
105
src/api/MicCheck.Api/Projects/ProjectService.cs
Normal file
@@ -0,0 +1,105 @@
|
||||
using MicCheck.Api.Audit;
|
||||
using MicCheck.Api.Authorization;
|
||||
using MicCheck.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MicCheck.Api.Projects;
|
||||
|
||||
public class ProjectService(MicCheckDbContext db, AuditService auditService)
|
||||
{
|
||||
public async Task<IReadOnlyList<Project>> ListByOrganizationAsync(int organizationId, CancellationToken ct = default)
|
||||
{
|
||||
return await db.Projects
|
||||
.Where(p => p.OrganizationId == organizationId)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<Project?> FindByIdAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
return await db.Projects.FirstOrDefaultAsync(p => p.Id == id, ct);
|
||||
}
|
||||
|
||||
public async Task<Project> CreateAsync(int organizationId, string name, CancellationToken ct = default)
|
||||
{
|
||||
var project = new Project
|
||||
{
|
||||
Name = name,
|
||||
OrganizationId = organizationId,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
db.Projects.Add(project);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
await auditService.LogAsync("Project", project.Id.ToString(), "created", organizationId, project.Id, ct: ct);
|
||||
|
||||
return project;
|
||||
}
|
||||
|
||||
public async Task<Project> UpdateAsync(int id, string name, bool hideDisabledFlags, CancellationToken ct = default)
|
||||
{
|
||||
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == id, ct)
|
||||
?? throw new KeyNotFoundException($"Project {id} not found.");
|
||||
|
||||
project.Name = name;
|
||||
project.HideDisabledFlags = hideDisabledFlags;
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
await auditService.LogAsync("Project", project.Id.ToString(), "updated", project.OrganizationId, project.Id, ct: ct);
|
||||
|
||||
return project;
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == id, ct);
|
||||
if (project is null) return;
|
||||
|
||||
db.Projects.Remove(project);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<UserProjectPermission>> ListUserPermissionsAsync(int projectId, CancellationToken ct = default)
|
||||
{
|
||||
return await db.UserProjectPermissions
|
||||
.Where(p => p.ProjectId == projectId)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<UserProjectPermission> SetUserPermissionsAsync(
|
||||
int projectId, int userId, bool isAdmin, List<ProjectPermission> permissions, CancellationToken ct = default)
|
||||
{
|
||||
var existing = await db.UserProjectPermissions
|
||||
.FirstOrDefaultAsync(p => p.ProjectId == projectId && p.UserId == userId, ct);
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
existing.IsAdmin = isAdmin;
|
||||
existing.Permissions = permissions;
|
||||
}
|
||||
else
|
||||
{
|
||||
existing = new UserProjectPermission
|
||||
{
|
||||
ProjectId = projectId,
|
||||
UserId = userId,
|
||||
IsAdmin = isAdmin,
|
||||
Permissions = permissions
|
||||
};
|
||||
db.UserProjectPermissions.Add(existing);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return existing;
|
||||
}
|
||||
|
||||
public async Task RemoveUserPermissionsAsync(int projectId, int userId, CancellationToken ct = default)
|
||||
{
|
||||
var perm = await db.UserProjectPermissions
|
||||
.FirstOrDefaultAsync(p => p.ProjectId == projectId && p.UserId == userId, ct);
|
||||
|
||||
if (perm is null) return;
|
||||
|
||||
db.UserProjectPermissions.Remove(perm);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
113
src/api/MicCheck.Api/Projects/ProjectsController.cs
Normal file
113
src/api/MicCheck.Api/Projects/ProjectsController.cs
Normal file
@@ -0,0 +1,113 @@
|
||||
using MicCheck.Api.Authorization;
|
||||
using MicCheck.Api.Common;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
|
||||
namespace MicCheck.Api.Projects;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/projects")]
|
||||
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
|
||||
[EnableRateLimiting("AdminApi")]
|
||||
public class ProjectsController(ProjectService projectService) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<PaginatedResponse<ProjectResponse>>> List(
|
||||
[FromQuery] int organizationId,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||
var all = await projectService.ListByOrganizationAsync(organizationId, ct);
|
||||
var paged = all.Skip((page - 1) * pageSize).Take(pageSize).Select(ProjectResponse.From).ToList();
|
||||
return Ok(new PaginatedResponse<ProjectResponse>(all.Count, null, null, paged));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<ProjectResponse>> Create(CreateProjectRequest request, CancellationToken ct)
|
||||
{
|
||||
var project = await projectService.CreateAsync(request.OrganizationId, request.Name, ct);
|
||||
return CreatedAtAction(nameof(GetById), new { id = project.Id }, ProjectResponse.From(project));
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<ProjectResponse>> GetById(int id, CancellationToken ct)
|
||||
{
|
||||
var project = await projectService.FindByIdAsync(id, ct);
|
||||
if (project is null) return NotFound();
|
||||
return Ok(ProjectResponse.From(project));
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<ActionResult<ProjectResponse>> Update(int id, UpdateProjectRequest request, CancellationToken ct)
|
||||
{
|
||||
var project = await projectService.FindByIdAsync(id, ct);
|
||||
if (project is null) return NotFound();
|
||||
|
||||
var updated = await projectService.UpdateAsync(id, request.Name, request.HideDisabledFlags, ct);
|
||||
return Ok(ProjectResponse.From(updated));
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> Delete(int id, CancellationToken ct)
|
||||
{
|
||||
var project = await projectService.FindByIdAsync(id, ct);
|
||||
if (project is null) return NotFound();
|
||||
|
||||
await projectService.DeleteAsync(id, ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("{id}/user-permissions")]
|
||||
public async Task<ActionResult<IReadOnlyList<UserPermissionResponse>>> ListUserPermissions(
|
||||
int id, CancellationToken ct)
|
||||
{
|
||||
var project = await projectService.FindByIdAsync(id, ct);
|
||||
if (project is null) return NotFound();
|
||||
|
||||
var perms = await projectService.ListUserPermissionsAsync(id, ct);
|
||||
return Ok(perms.Select(UserPermissionResponse.From).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("{id}/user-permissions")]
|
||||
public async Task<ActionResult<UserPermissionResponse>> CreateUserPermissions(
|
||||
int id, SetUserPermissionsRequest request, CancellationToken ct)
|
||||
{
|
||||
var project = await projectService.FindByIdAsync(id, ct);
|
||||
if (project is null) return NotFound();
|
||||
|
||||
var permissions = request.Permissions
|
||||
.Select(p => Enum.Parse<Authorization.ProjectPermission>(p, ignoreCase: true))
|
||||
.ToList();
|
||||
|
||||
var perm = await projectService.SetUserPermissionsAsync(id, request.UserId, request.IsAdmin, permissions, ct);
|
||||
return Ok(UserPermissionResponse.From(perm));
|
||||
}
|
||||
|
||||
[HttpPut("{id}/user-permissions/{userId}")]
|
||||
public async Task<ActionResult<UserPermissionResponse>> UpdateUserPermissions(
|
||||
int id, int userId, SetUserPermissionsRequest request, CancellationToken ct)
|
||||
{
|
||||
var project = await projectService.FindByIdAsync(id, ct);
|
||||
if (project is null) return NotFound();
|
||||
|
||||
var permissions = request.Permissions
|
||||
.Select(p => Enum.Parse<Authorization.ProjectPermission>(p, ignoreCase: true))
|
||||
.ToList();
|
||||
|
||||
var perm = await projectService.SetUserPermissionsAsync(id, userId, request.IsAdmin, permissions, ct);
|
||||
return Ok(UserPermissionResponse.From(perm));
|
||||
}
|
||||
|
||||
[HttpDelete("{id}/user-permissions/{userId}")]
|
||||
public async Task<IActionResult> DeleteUserPermissions(int id, int userId, CancellationToken ct)
|
||||
{
|
||||
var project = await projectService.FindByIdAsync(id, ct);
|
||||
if (project is null) return NotFound();
|
||||
|
||||
await projectService.RemoveUserPermissionsAsync(id, userId, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
17
src/api/MicCheck.Api/Projects/SetUserPermissionsRequest.cs
Normal file
17
src/api/MicCheck.Api/Projects/SetUserPermissionsRequest.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using FluentValidation;
|
||||
using MicCheck.Api.Authorization;
|
||||
|
||||
namespace MicCheck.Api.Projects;
|
||||
|
||||
public record SetUserPermissionsRequest(int UserId, bool IsAdmin, List<string> Permissions);
|
||||
|
||||
public class SetUserPermissionsRequestValidator : AbstractValidator<SetUserPermissionsRequest>
|
||||
{
|
||||
public SetUserPermissionsRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.UserId).GreaterThan(0);
|
||||
RuleForEach(x => x.Permissions)
|
||||
.Must(p => Enum.TryParse<ProjectPermission>(p, true, out _))
|
||||
.WithMessage("Invalid permission value.");
|
||||
}
|
||||
}
|
||||
13
src/api/MicCheck.Api/Projects/UpdateProjectRequest.cs
Normal file
13
src/api/MicCheck.Api/Projects/UpdateProjectRequest.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace MicCheck.Api.Projects;
|
||||
|
||||
public record UpdateProjectRequest(string Name, bool HideDisabledFlags);
|
||||
|
||||
public class UpdateProjectRequestValidator : AbstractValidator<UpdateProjectRequest>
|
||||
{
|
||||
public UpdateProjectRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
|
||||
}
|
||||
}
|
||||
39
src/api/MicCheck.Api/Segments/CreateSegmentRequest.cs
Normal file
39
src/api/MicCheck.Api/Segments/CreateSegmentRequest.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace MicCheck.Api.Segments;
|
||||
|
||||
public record CreateSegmentConditionRequest(
|
||||
string Property,
|
||||
string Operator,
|
||||
string Value);
|
||||
|
||||
public record CreateSegmentRuleRequest(
|
||||
string Type,
|
||||
IReadOnlyList<CreateSegmentConditionRequest> Conditions,
|
||||
IReadOnlyList<CreateSegmentRuleRequest>? ChildRules = null);
|
||||
|
||||
public record CreateSegmentRequest(
|
||||
string Name,
|
||||
IReadOnlyList<CreateSegmentRuleRequest> Rules);
|
||||
|
||||
public class CreateSegmentRequestValidator : AbstractValidator<CreateSegmentRequest>
|
||||
{
|
||||
public CreateSegmentRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
|
||||
RuleFor(x => x.Rules).NotNull();
|
||||
RuleForEach(x => x.Rules).ChildRules(rule =>
|
||||
{
|
||||
rule.RuleFor(r => r.Type)
|
||||
.Must(t => Enum.TryParse<SegmentRuleType>(t, true, out _))
|
||||
.WithMessage("Rule type must be 'All', 'Any', or 'None'.");
|
||||
rule.RuleForEach(r => r.Conditions).ChildRules(cond =>
|
||||
{
|
||||
cond.RuleFor(c => c.Property).NotEmpty();
|
||||
cond.RuleFor(c => c.Operator)
|
||||
.Must(o => Enum.TryParse<SegmentConditionOperator>(o, true, out _))
|
||||
.WithMessage("Invalid operator.");
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
51
src/api/MicCheck.Api/Segments/SegmentResponse.cs
Normal file
51
src/api/MicCheck.Api/Segments/SegmentResponse.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
namespace MicCheck.Api.Segments;
|
||||
|
||||
public record SegmentResponse(
|
||||
int Id,
|
||||
string Name,
|
||||
int ProjectId,
|
||||
DateTimeOffset CreatedAt,
|
||||
IReadOnlyList<SegmentRuleResponse> Rules
|
||||
)
|
||||
{
|
||||
public static SegmentResponse From(Segment segment) => new(
|
||||
segment.Id,
|
||||
segment.Name,
|
||||
segment.ProjectId,
|
||||
segment.CreatedAt,
|
||||
segment.Rules
|
||||
.Where(r => r.ParentRuleId == null)
|
||||
.Select(r => SegmentRuleResponse.From(r, segment.Rules.ToList()))
|
||||
.ToList());
|
||||
}
|
||||
|
||||
public record SegmentRuleResponse(
|
||||
int Id,
|
||||
string Type,
|
||||
IReadOnlyList<SegmentConditionResponse> Conditions,
|
||||
IReadOnlyList<SegmentRuleResponse> ChildRules
|
||||
)
|
||||
{
|
||||
public static SegmentRuleResponse From(SegmentRule rule, List<SegmentRule> allRules) => new(
|
||||
rule.Id,
|
||||
rule.Type.ToString(),
|
||||
rule.Conditions.Select(SegmentConditionResponse.From).ToList(),
|
||||
allRules
|
||||
.Where(r => r.ParentRuleId == rule.Id)
|
||||
.Select(r => SegmentRuleResponse.From(r, allRules))
|
||||
.ToList());
|
||||
}
|
||||
|
||||
public record SegmentConditionResponse(
|
||||
int Id,
|
||||
string Property,
|
||||
string Operator,
|
||||
string Value
|
||||
)
|
||||
{
|
||||
public static SegmentConditionResponse From(SegmentCondition condition) => new(
|
||||
condition.Id,
|
||||
condition.Property,
|
||||
condition.Operator.ToString(),
|
||||
condition.Value);
|
||||
}
|
||||
166
src/api/MicCheck.Api/Segments/SegmentService.cs
Normal file
166
src/api/MicCheck.Api/Segments/SegmentService.cs
Normal file
@@ -0,0 +1,166 @@
|
||||
using MicCheck.Api.Audit;
|
||||
using MicCheck.Api.Common;
|
||||
using MicCheck.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MicCheck.Api.Segments;
|
||||
|
||||
public record SegmentConditionDefinition(
|
||||
string Property,
|
||||
SegmentConditionOperator Operator,
|
||||
string Value);
|
||||
|
||||
public record SegmentRuleDefinition(
|
||||
SegmentRuleType Type,
|
||||
IReadOnlyList<SegmentConditionDefinition> Conditions,
|
||||
IReadOnlyList<SegmentRuleDefinition>? ChildRules = null);
|
||||
|
||||
public class SegmentService(MicCheckDbContext db, AuditService auditService)
|
||||
{
|
||||
private const int MaxSegmentsPerProject = 100;
|
||||
private const int MaxConditionsPerSegment = 100;
|
||||
|
||||
public async Task<IReadOnlyList<Segment>> ListByProjectAsync(int projectId, CancellationToken ct = default)
|
||||
{
|
||||
return await db.Segments
|
||||
.Include(s => s.Rules)
|
||||
.ThenInclude(r => r.Conditions)
|
||||
.Where(s => s.ProjectId == projectId)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<Segment?> FindByIdAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
return await db.Segments
|
||||
.Include(s => s.Rules)
|
||||
.ThenInclude(r => r.Conditions)
|
||||
.FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||
}
|
||||
|
||||
public async Task<Segment> CreateAsync(
|
||||
int projectId,
|
||||
string name,
|
||||
IReadOnlyList<SegmentRuleDefinition> rules,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var count = await db.Segments.CountAsync(s => s.ProjectId == projectId, ct);
|
||||
if (count >= MaxSegmentsPerProject)
|
||||
throw new DomainException($"Project has reached the maximum of {MaxSegmentsPerProject} segments.");
|
||||
|
||||
var totalConditions = rules.Sum(r => CountConditions(r));
|
||||
if (totalConditions > MaxConditionsPerSegment)
|
||||
throw new DomainException($"Segment cannot have more than {MaxConditionsPerSegment} conditions.");
|
||||
|
||||
var segment = new Segment
|
||||
{
|
||||
Name = name,
|
||||
ProjectId = projectId,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
db.Segments.Add(segment);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
foreach (var ruleDef in rules)
|
||||
{
|
||||
AddRule(segment.Id, ruleDef, parentRuleId: null);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == projectId, ct);
|
||||
if (project is not null)
|
||||
await auditService.LogAsync("Segment", segment.Id.ToString(), "created",
|
||||
project.OrganizationId, projectId, ct: ct);
|
||||
|
||||
return segment;
|
||||
}
|
||||
|
||||
public async Task<Segment> UpdateAsync(
|
||||
int id,
|
||||
string name,
|
||||
IReadOnlyList<SegmentRuleDefinition> rules,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var segment = await db.Segments
|
||||
.Include(s => s.Rules)
|
||||
.ThenInclude(r => r.Conditions)
|
||||
.FirstOrDefaultAsync(s => s.Id == id, ct)
|
||||
?? throw new KeyNotFoundException($"Segment {id} not found.");
|
||||
|
||||
var totalConditions = rules.Sum(r => CountConditions(r));
|
||||
if (totalConditions > MaxConditionsPerSegment)
|
||||
throw new DomainException($"Segment cannot have more than {MaxConditionsPerSegment} conditions.");
|
||||
|
||||
var existingRules = await db.SegmentRules
|
||||
.Where(r => r.SegmentId == id)
|
||||
.ToListAsync(ct);
|
||||
var existingConditions = await db.SegmentConditions
|
||||
.Where(c => existingRules.Select(r => r.Id).Contains(c.RuleId))
|
||||
.ToListAsync(ct);
|
||||
|
||||
db.SegmentConditions.RemoveRange(existingConditions);
|
||||
db.SegmentRules.RemoveRange(existingRules);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
segment.Name = name;
|
||||
|
||||
foreach (var ruleDef in rules)
|
||||
{
|
||||
AddRule(segment.Id, ruleDef, parentRuleId: null);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == segment.ProjectId, ct);
|
||||
if (project is not null)
|
||||
await auditService.LogAsync("Segment", segment.Id.ToString(), "updated",
|
||||
project.OrganizationId, segment.ProjectId, ct: ct);
|
||||
|
||||
return segment;
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
var segment = await db.Segments.FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||
if (segment is null) return;
|
||||
|
||||
db.Segments.Remove(segment);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private void AddRule(int segmentId, SegmentRuleDefinition def, int? parentRuleId)
|
||||
{
|
||||
var rule = new SegmentRule
|
||||
{
|
||||
SegmentId = segmentId,
|
||||
ParentRuleId = parentRuleId,
|
||||
Type = def.Type
|
||||
};
|
||||
foreach (var cond in def.Conditions)
|
||||
{
|
||||
rule.Conditions.Add(new SegmentCondition
|
||||
{
|
||||
Property = cond.Property,
|
||||
Operator = cond.Operator,
|
||||
Value = cond.Value
|
||||
});
|
||||
}
|
||||
db.SegmentRules.Add(rule);
|
||||
|
||||
if (def.ChildRules is not null)
|
||||
{
|
||||
foreach (var child in def.ChildRules)
|
||||
{
|
||||
AddRule(segmentId, child, rule.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int CountConditions(SegmentRuleDefinition rule)
|
||||
{
|
||||
var count = rule.Conditions.Count;
|
||||
if (rule.ChildRules is not null)
|
||||
count += rule.ChildRules.Sum(CountConditions);
|
||||
return count;
|
||||
}
|
||||
}
|
||||
90
src/api/MicCheck.Api/Segments/SegmentsController.cs
Normal file
90
src/api/MicCheck.Api/Segments/SegmentsController.cs
Normal file
@@ -0,0 +1,90 @@
|
||||
using MicCheck.Api.Authorization;
|
||||
using MicCheck.Api.Common;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
|
||||
namespace MicCheck.Api.Segments;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/projects/{projectId}/segments")]
|
||||
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
|
||||
[EnableRateLimiting("AdminApi")]
|
||||
public class SegmentsController(SegmentService segmentService) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<PaginatedResponse<SegmentResponse>>> List(
|
||||
int projectId,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||
var all = await segmentService.ListByProjectAsync(projectId, ct);
|
||||
var paged = all.Skip((page - 1) * pageSize).Take(pageSize).Select(SegmentResponse.From).ToList();
|
||||
return Ok(new PaginatedResponse<SegmentResponse>(all.Count, null, null, paged));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<SegmentResponse>> Create(
|
||||
int projectId, CreateSegmentRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var rules = MapRules(request.Rules);
|
||||
var segment = await segmentService.CreateAsync(projectId, request.Name, rules, ct);
|
||||
return CreatedAtAction(nameof(GetById), new { projectId, id = segment.Id }, SegmentResponse.From(segment));
|
||||
}
|
||||
catch (DomainException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<SegmentResponse>> GetById(int projectId, int id, CancellationToken ct)
|
||||
{
|
||||
var segment = await segmentService.FindByIdAsync(id, ct);
|
||||
if (segment is null || segment.ProjectId != projectId) return NotFound();
|
||||
return Ok(SegmentResponse.From(segment));
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<ActionResult<SegmentResponse>> Update(
|
||||
int projectId, int id, CreateSegmentRequest request, CancellationToken ct)
|
||||
{
|
||||
var segment = await segmentService.FindByIdAsync(id, ct);
|
||||
if (segment is null || segment.ProjectId != projectId) return NotFound();
|
||||
|
||||
try
|
||||
{
|
||||
var rules = MapRules(request.Rules);
|
||||
var updated = await segmentService.UpdateAsync(id, request.Name, rules, ct);
|
||||
return Ok(SegmentResponse.From(updated));
|
||||
}
|
||||
catch (DomainException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> Delete(int projectId, int id, CancellationToken ct)
|
||||
{
|
||||
var segment = await segmentService.FindByIdAsync(id, ct);
|
||||
if (segment is null || segment.ProjectId != projectId) return NotFound();
|
||||
|
||||
await segmentService.DeleteAsync(id, ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SegmentRuleDefinition> MapRules(IReadOnlyList<CreateSegmentRuleRequest> rules) =>
|
||||
rules.Select(r => new SegmentRuleDefinition(
|
||||
Enum.Parse<SegmentRuleType>(r.Type, ignoreCase: true),
|
||||
r.Conditions.Select(c => new SegmentConditionDefinition(
|
||||
c.Property,
|
||||
Enum.Parse<SegmentConditionOperator>(c.Operator, ignoreCase: true),
|
||||
c.Value)).ToList(),
|
||||
r.ChildRules is not null ? MapRules(r.ChildRules) : null
|
||||
)).ToList();
|
||||
}
|
||||
14
src/api/MicCheck.Api/Webhooks/CreateWebhookRequest.cs
Normal file
14
src/api/MicCheck.Api/Webhooks/CreateWebhookRequest.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace MicCheck.Api.Webhooks;
|
||||
|
||||
public record CreateWebhookRequest(string Url, string? Secret, bool Enabled);
|
||||
|
||||
public class CreateWebhookRequestValidator : AbstractValidator<CreateWebhookRequest>
|
||||
{
|
||||
public CreateWebhookRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Url).NotEmpty().MaximumLength(500).Must(u => Uri.TryCreate(u, UriKind.Absolute, out _))
|
||||
.WithMessage("Url must be a valid absolute URL.");
|
||||
}
|
||||
}
|
||||
23
src/api/MicCheck.Api/Webhooks/WebhookResponse.cs
Normal file
23
src/api/MicCheck.Api/Webhooks/WebhookResponse.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
namespace MicCheck.Api.Webhooks;
|
||||
|
||||
public record WebhookResponse(
|
||||
int Id,
|
||||
string Url,
|
||||
string? Secret,
|
||||
string Scope,
|
||||
bool Enabled,
|
||||
int? EnvironmentId,
|
||||
int? OrganizationId,
|
||||
DateTimeOffset CreatedAt
|
||||
)
|
||||
{
|
||||
public static WebhookResponse From(Webhook webhook) => new(
|
||||
webhook.Id,
|
||||
webhook.Url,
|
||||
webhook.Secret,
|
||||
webhook.Scope.ToString(),
|
||||
webhook.Enabled,
|
||||
webhook.EnvironmentId,
|
||||
webhook.OrganizationId,
|
||||
webhook.CreatedAt);
|
||||
}
|
||||
56
src/api/MicCheck.Api/Webhooks/WebhookService.cs
Normal file
56
src/api/MicCheck.Api/Webhooks/WebhookService.cs
Normal file
@@ -0,0 +1,56 @@
|
||||
using MicCheck.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MicCheck.Api.Webhooks;
|
||||
|
||||
public class WebhookService(MicCheckDbContext db)
|
||||
{
|
||||
public async Task<IReadOnlyList<Webhook>> ListByEnvironmentAsync(int environmentId, CancellationToken ct = default)
|
||||
{
|
||||
return await db.Webhooks
|
||||
.Where(w => w.EnvironmentId == environmentId)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<Webhook?> FindByIdAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
return await db.Webhooks.FirstOrDefaultAsync(w => w.Id == id, ct);
|
||||
}
|
||||
|
||||
public async Task<Webhook> CreateAsync(int environmentId, string url, string? secret, bool enabled, CancellationToken ct = default)
|
||||
{
|
||||
var webhook = new Webhook
|
||||
{
|
||||
Url = url,
|
||||
Secret = secret,
|
||||
Scope = WebhookScope.Environment,
|
||||
EnvironmentId = environmentId,
|
||||
Enabled = enabled,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
db.Webhooks.Add(webhook);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return webhook;
|
||||
}
|
||||
|
||||
public async Task<Webhook> UpdateAsync(int id, string url, string? secret, bool enabled, CancellationToken ct = default)
|
||||
{
|
||||
var webhook = await db.Webhooks.FirstOrDefaultAsync(w => w.Id == id, ct)
|
||||
?? throw new KeyNotFoundException($"Webhook {id} not found.");
|
||||
|
||||
webhook.Url = url;
|
||||
webhook.Secret = secret;
|
||||
webhook.Enabled = enabled;
|
||||
await db.SaveChangesAsync(ct);
|
||||
return webhook;
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
var webhook = await db.Webhooks.FirstOrDefaultAsync(w => w.Id == id, ct);
|
||||
if (webhook is null) return;
|
||||
|
||||
db.Webhooks.Remove(webhook);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user