Adding Admin API

This commit is contained in:
2026-04-08 14:25:10 -07:00
parent 0ba076b650
commit b9a04df861
67 changed files with 3316 additions and 74 deletions

View 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);
}
}

View 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);
}

View 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());
}
}

View 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);
}
}

View File

@@ -0,0 +1,3 @@
namespace MicCheck.Api.Common;
public class DomainException(string message) : Exception(message);

View File

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

View 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);
}
}

View File

@@ -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);
}
}

View 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);
}

View 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;
}
}

View 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));
}
}

View File

@@ -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);
}
}

View 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);
}
}

View 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).");
}
}

View 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);
}

View 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);
}
}

View 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);
}

View 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;
}
}

View 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();
}
}

View File

@@ -0,0 +1,7 @@
namespace MicCheck.Api.Features;
public record PatchFeatureRequest(
string? Name,
string? Description,
bool? DefaultEnabled
);

View 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);
}

View 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);
}
}

View 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();
}
}

View 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.");
}
}

View File

@@ -0,0 +1,5 @@
namespace MicCheck.Api.Features;
public record UpdateFeatureStateRequest(bool Enabled, string? Value);
public record PatchFeatureStateRequest(bool? Enabled, string? Value);

View 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());
}

View 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);
}
}

View File

@@ -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>

View File

@@ -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);
}
}

View 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'.");
}
}

View 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());
}

View 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);
}
}

View 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;
}
}

View File

@@ -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);
}
}

View File

@@ -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 { }

View 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);
}
}

View 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());
}

View 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);
}
}

View 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();
}
}

View 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.");
}
}

View 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);
}
}

View 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.");
});
});
}
}

View 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);
}

View 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;
}
}

View 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();
}

View 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.");
}
}

View 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);
}

View 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);
}
}