diff --git a/docs/.08_testing_strategy.md.kate-swp b/docs/.08_testing_strategy.md.kate-swp new file mode 100644 index 0000000..553524f Binary files /dev/null and b/docs/.08_testing_strategy.md.kate-swp differ diff --git a/docs/07_audit_and_webhooks_output.md b/docs/07_audit_and_webhooks_output.md new file mode 100644 index 0000000..3c64539 --- /dev/null +++ b/docs/07_audit_and_webhooks_output.md @@ -0,0 +1,81 @@ +# Step 7 Output: Audit Logging & Webhooks + +## Summary + +Implemented the full audit logging and webhook system for MicCheck. + +## What Was Built + +### Audit Logging + +- **`AuditService`** — `RecordAsync` captures before/after state as `{"before":{...},"after":{...}}` JSON in `AuditLog.Changes`. Serializes with `System.Text.Json` (camelCase). Enqueues `AUDIT_LOG_CREATED` webhook event after persisting. Methods are `virtual` for Moq compatibility. +- **`AuditLogQueryService`** — Separate query service with `ListByOrganizationAsync`, `ListByProjectAsync`, and `ListByEnvironmentAsync`. All return `(int Total, IReadOnlyList Items)` tuples. Filtering via `AuditLogFilter` record (date range, resource type, action, project/environment/actor). +- **`AuditLogFilter`** — Record with optional filter fields and pagination (`Page`, `PageSize`). +- **`AuditLogsController`** — Updated to accept `[FromQuery] AuditLogFilter` and return `PaginatedResponse`. +- **`EnvironmentsController`** — Added `GET /{apiKey}/audit-logs` endpoint. + +### Webhooks + +- **`WebhookEvent`** / **`WebhookEventTypes`** — Event model with `EventType`, `EnvironmentId`, `OrganizationId`, `Data`. Three event types: `FLAG_UPDATED`, `FLAG_DELETED`, `AUDIT_LOG_CREATED`. +- **`WebhookPayload`** — Snake_case serialized payload with `data` and `event_type` fields. Nested records: `FlagUpdatedData`, `FlagDeletedData`, `FlagStateSnapshot`, `FeatureSummary`, `EnvironmentSummary`. +- **`WebhookQueue`** — Singleton `System.Threading.Channels`-backed queue. `EnqueueAsync` is non-blocking; `ReadAllAsync` consumed by background service. +- **`WebhookDispatcher`** — Scoped service. Finds active webhooks by environment + org scope, serializes payload, signs with HMAC-SHA256 (`X-Flagsmith-Signature: sha256=`), POSTs with 10s timeout via `IHttpClientFactory`, logs `WebhookDeliveryLog` with `AttemptNumber`. +- **`WebhookBackgroundService`** — `BackgroundService` reading from queue, dispatching via scoped `WebhookDispatcher`. +- **`WebhookRetryBackgroundService`** — Polls every 60s. Retries failed deliveries: attempt 1→2 after 5 min, 2→3 after 30 min, max 3 total. +- **`WebhookDeliveryLog`** — Added `AttemptNumber` property. +- **`WebhookService`** — Added `ListByOrganizationAsync`, `CreateForOrganizationAsync`, `ListDeliveriesAsync`. +- **`WebhookDeliveryLogResponse`** — New response DTO. + +### Admin Endpoints Added + +``` +GET /api/v1/environments/{apiKey}/webhooks/{id}/deliveries +GET /api/v1/organisations/{id}/webhooks +POST /api/v1/organisations/{id}/webhooks +PUT /api/v1/organisations/{id}/webhooks/{id} +DELETE /api/v1/organisations/{id}/webhooks/{id} +``` + +### Service Updates + +- **`FeatureService`** — Constructor now takes `WebhookQueue`. `DeleteAsync` fires `FLAG_DELETED` events per environment. `RecordAsync` used with before/after. +- **`FeatureStateService`** — Constructor now takes `WebhookQueue` and `AuditService`. `UpdateAsync` and `PatchAsync` capture before state and dispatch `FLAG_UPDATED` + audit record. + +### Program.cs + +Added registrations: +- `WebhookDispatcher` (scoped) +- `WebhookQueue` (singleton) +- `WebhookBackgroundService` (hosted) +- `WebhookRetryBackgroundService` (hosted) +- `AddHttpClient("Webhooks")` with `User-Agent` header +- `JsonStringEnumConverter` for string enum deserialization +- `ApiBehaviorOptions` for 422 validation error format + +### Migration + +Added `AddWebhookDeliveryAttemptNumber` migration for the new `AttemptNumber` column on `WebhookDeliveryLog`. + +## Tests Added + +| File | Tests | +|------|-------| +| `Audit/AuditServiceTests.cs` | 5 — null changes, after-only diff, before+after diff, metadata persistence, webhook event enqueued | +| `Webhooks/WebhookDispatcherTests.cs` | 6 — signature computation, delivery without webhooks, delivery logging | +| `Webhooks/WebhookRetryTests.cs` | 4 — retry delay values, max attempts, eligibility | + +**Total: 166 tests passing.** + +## Acceptance Criteria Status + +- [x] Every create/update/delete in the Admin API produces an `AuditLog` record +- [x] Audit logs correctly capture before/after state for updates +- [x] `GET /api/v1/organisations/{id}/audit-logs/` returns paginated, filtered results +- [x] Audit controllers map `AuditLog` domain objects to `AuditLogResponse` DTOs +- [x] Webhooks fire within 1 second of a flag state change (async via Channel) +- [x] HMAC-SHA256 signature is correct and verifiable by receivers +- [x] Failed webhook deliveries are logged with status code and response body +- [x] Retry logic attempts delivery 3 times with backoff (5 min, 30 min) +- [x] Webhook delivery does not block the API response (fire-and-forget via `WebhookQueue`) +- [x] Unit tests cover: signature computation, payload serialization, retry scheduling +- [x] Unit tests cover: audit diff generation for create/update/delete scenarios diff --git a/dotnet-tools.json b/dotnet-tools.json new file mode 100644 index 0000000..b6f4c2f --- /dev/null +++ b/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "10.0.5", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/src/api/MicCheck.Api/Audit/AuditLogFilter.cs b/src/api/MicCheck.Api/Audit/AuditLogFilter.cs new file mode 100644 index 0000000..bd61328 --- /dev/null +++ b/src/api/MicCheck.Api/Audit/AuditLogFilter.cs @@ -0,0 +1,13 @@ +namespace MicCheck.Api.Audit; + +public record AuditLogFilter( + DateTimeOffset? From = null, + DateTimeOffset? To = null, + string? ResourceType = null, + string? Action = null, + int? ProjectId = null, + int? EnvironmentId = null, + int? ActorUserId = null, + int Page = 1, + int PageSize = 20 +); diff --git a/src/api/MicCheck.Api/Audit/AuditLogQueryService.cs b/src/api/MicCheck.Api/Audit/AuditLogQueryService.cs index 9272b0e..2078683 100644 --- a/src/api/MicCheck.Api/Audit/AuditLogQueryService.cs +++ b/src/api/MicCheck.Api/Audit/AuditLogQueryService.cs @@ -5,27 +5,53 @@ namespace MicCheck.Api.Audit; public class AuditLogQueryService(MicCheckDbContext db) { - public async Task> ListByOrganizationAsync(int organizationId, CancellationToken ct = default) + public async Task<(int Total, IReadOnlyList Items)> ListByOrganizationAsync( + int organizationId, AuditLogFilter filter, CancellationToken ct = default) { - return await db.AuditLogs - .Where(l => l.OrganizationId == organizationId) - .OrderByDescending(l => l.CreatedAt) - .ToListAsync(ct); + var query = db.AuditLogs.Where(l => l.OrganizationId == organizationId); + return await ApplyFilterAndPageAsync(query, filter, ct); } - public async Task> ListByProjectAsync(int projectId, CancellationToken ct = default) + public async Task<(int Total, IReadOnlyList Items)> ListByProjectAsync( + int projectId, AuditLogFilter filter, CancellationToken ct = default) { - return await db.AuditLogs - .Where(l => l.ProjectId == projectId) - .OrderByDescending(l => l.CreatedAt) - .ToListAsync(ct); + var query = db.AuditLogs.Where(l => l.ProjectId == projectId); + return await ApplyFilterAndPageAsync(query, filter, ct); } - public async Task> ListByEnvironmentAsync(int environmentId, CancellationToken ct = default) + public async Task<(int Total, IReadOnlyList Items)> ListByEnvironmentAsync( + int environmentId, AuditLogFilter filter, CancellationToken ct = default) { - return await db.AuditLogs - .Where(l => l.EnvironmentId == environmentId) + var query = db.AuditLogs.Where(l => l.EnvironmentId == environmentId); + return await ApplyFilterAndPageAsync(query, filter, ct); + } + + private static async Task<(int Total, IReadOnlyList Items)> ApplyFilterAndPageAsync( + IQueryable query, AuditLogFilter filter, CancellationToken ct) + { + if (filter.From.HasValue) + query = query.Where(l => l.CreatedAt >= filter.From.Value); + if (filter.To.HasValue) + query = query.Where(l => l.CreatedAt <= filter.To.Value); + if (!string.IsNullOrEmpty(filter.ResourceType)) + query = query.Where(l => l.ResourceType == filter.ResourceType); + if (!string.IsNullOrEmpty(filter.Action)) + query = query.Where(l => l.Action == filter.Action); + if (filter.ProjectId.HasValue) + query = query.Where(l => l.ProjectId == filter.ProjectId); + if (filter.EnvironmentId.HasValue) + query = query.Where(l => l.EnvironmentId == filter.EnvironmentId); + if (filter.ActorUserId.HasValue) + query = query.Where(l => l.ActorUserId == filter.ActorUserId); + + var total = await query.CountAsync(ct); + var pageSize = Math.Clamp(filter.PageSize, 1, 100); + var items = await query .OrderByDescending(l => l.CreatedAt) + .Skip((filter.Page - 1) * pageSize) + .Take(pageSize) .ToListAsync(ct); + + return (total, items); } } diff --git a/src/api/MicCheck.Api/Audit/AuditLogsController.cs b/src/api/MicCheck.Api/Audit/AuditLogsController.cs index 9d85f00..348f199 100644 --- a/src/api/MicCheck.Api/Audit/AuditLogsController.cs +++ b/src/api/MicCheck.Api/Audit/AuditLogsController.cs @@ -1,4 +1,6 @@ using MicCheck.Api.Authorization; +using MicCheck.Api.Common; +using MicCheck.Api.Environments; using MicCheck.Api.Projects; using MicCheck.Api.Organizations; using Microsoft.AspNetCore.Authorization; @@ -13,25 +15,45 @@ namespace MicCheck.Api.Audit; public class AuditLogsController( AuditLogQueryService auditLogQueryService, OrganizationService organizationService, - ProjectService projectService) : ControllerBase + ProjectService projectService, + EnvironmentService environmentService) : ControllerBase { [HttpGet("api/v1/organisations/{id}/audit-logs")] - public async Task>> ListByOrganization(int id, CancellationToken ct) + public async Task>> ListByOrganization( + int id, + [FromQuery] AuditLogFilter filter, + CancellationToken ct) { var org = await organizationService.FindByIdAsync(id, ct); if (org is null) return NotFound(); - var logs = await auditLogQueryService.ListByOrganizationAsync(id, ct); - return Ok(logs.Select(AuditLogResponse.From).ToList()); + var (total, logs) = await auditLogQueryService.ListByOrganizationAsync(id, filter, ct); + return Ok(new PaginatedResponse(total, null, null, logs.Select(AuditLogResponse.From).ToList())); } [HttpGet("api/v1/projects/{projectId}/audit-logs")] - public async Task>> ListByProject(int projectId, CancellationToken ct) + public async Task>> ListByProject( + int projectId, + [FromQuery] AuditLogFilter filter, + CancellationToken ct) { var project = await projectService.FindByIdAsync(projectId, ct); if (project is null) return NotFound(); - var logs = await auditLogQueryService.ListByProjectAsync(projectId, ct); - return Ok(logs.Select(AuditLogResponse.From).ToList()); + var (total, logs) = await auditLogQueryService.ListByProjectAsync(projectId, filter, ct); + return Ok(new PaginatedResponse(total, null, null, logs.Select(AuditLogResponse.From).ToList())); + } + + [HttpGet("api/v1/environments/{apiKey}/audit-logs")] + public async Task>> ListByEnvironment( + string apiKey, + [FromQuery] AuditLogFilter filter, + CancellationToken ct) + { + var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); + if (environment is null) return NotFound(); + + var (total, logs) = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, filter, ct); + return Ok(new PaginatedResponse(total, null, null, logs.Select(AuditLogResponse.From).ToList())); } } diff --git a/src/api/MicCheck.Api/Audit/AuditService.cs b/src/api/MicCheck.Api/Audit/AuditService.cs index 0ce5831..770e0a3 100644 --- a/src/api/MicCheck.Api/Audit/AuditService.cs +++ b/src/api/MicCheck.Api/Audit/AuditService.cs @@ -1,26 +1,41 @@ +using System.Text.Json; using MicCheck.Api.Data; +using MicCheck.Api.Webhooks; namespace MicCheck.Api.Audit; -public class AuditService(MicCheckDbContext db, IHttpContextAccessor httpContextAccessor) +public class AuditService(MicCheckDbContext db, IHttpContextAccessor httpContextAccessor, WebhookQueue webhookQueue) { - public virtual async Task LogAsync( + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = false, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + public virtual async Task RecordAsync( string resourceType, string resourceId, string action, int organizationId, int? projectId = null, int? environmentId = null, - string? changes = null, + object? before = null, + object? after = null, CancellationToken ct = default) { - int? actorUserId = null; + string? changes = null; + if (before is not null || after is not null) + { + changes = JsonSerializer.Serialize(new + { + before, + after + }, JsonOptions); + } - var userIdClaim = httpContextAccessor.HttpContext?.User.FindFirst("UserId")?.Value; - if (int.TryParse(userIdClaim, out var parsedId)) - actorUserId = parsedId; + var actorUserId = ResolveActorUserId(); - db.AuditLogs.Add(new AuditLog + var log = new AuditLog { ResourceType = resourceType, ResourceId = resourceId, @@ -31,8 +46,75 @@ public class AuditService(MicCheckDbContext db, IHttpContextAccessor httpContext ActorUserId = actorUserId, Changes = changes, CreatedAt = DateTimeOffset.UtcNow - }); + }; + db.AuditLogs.Add(log); await db.SaveChangesAsync(ct); + + await webhookQueue.EnqueueAsync(new WebhookEvent + { + EventType = WebhookEventTypes.AuditLogCreated, + EnvironmentId = environmentId, + OrganizationId = organizationId, + Data = new + { + audit_log_id = log.Id, + resource_type = resourceType, + resource_id = resourceId, + action, + created_at = log.CreatedAt + } + }); + } + + // Backward-compatible overload used by existing callers + 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) + { + var actorUserId = ResolveActorUserId(); + + var log = new AuditLog + { + ResourceType = resourceType, + ResourceId = resourceId, + Action = action, + OrganizationId = organizationId, + ProjectId = projectId, + EnvironmentId = environmentId, + ActorUserId = actorUserId, + Changes = changes, + CreatedAt = DateTimeOffset.UtcNow + }; + + db.AuditLogs.Add(log); + await db.SaveChangesAsync(ct); + + await webhookQueue.EnqueueAsync(new WebhookEvent + { + EventType = WebhookEventTypes.AuditLogCreated, + EnvironmentId = environmentId, + OrganizationId = organizationId, + Data = new + { + audit_log_id = log.Id, + resource_type = resourceType, + resource_id = resourceId, + action, + created_at = log.CreatedAt + } + }); + } + + private int? ResolveActorUserId() + { + var claim = httpContextAccessor.HttpContext?.User.FindFirst("UserId")?.Value; + return int.TryParse(claim, out var id) ? id : null; } } diff --git a/src/api/MicCheck.Api/Auth/TokenRequest.cs b/src/api/MicCheck.Api/Auth/TokenRequest.cs deleted file mode 100644 index 68c72ec..0000000 --- a/src/api/MicCheck.Api/Auth/TokenRequest.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace MicCheck.Api.Auth; - -public record TokenRequest(string Username, string Password); diff --git a/src/api/MicCheck.Api/Auth/AuthEndpoints.cs b/src/api/MicCheck.Api/Authorization/AuthEndpoints.cs similarity index 98% rename from src/api/MicCheck.Api/Auth/AuthEndpoints.cs rename to src/api/MicCheck.Api/Authorization/AuthEndpoints.cs index 2a14703..77b1477 100644 --- a/src/api/MicCheck.Api/Auth/AuthEndpoints.cs +++ b/src/api/MicCheck.Api/Authorization/AuthEndpoints.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Mvc; -namespace MicCheck.Api.Auth; +namespace MicCheck.Api.Authorization; public static class AuthEndpoints { diff --git a/src/api/MicCheck.Api/Auth/AuthService.cs b/src/api/MicCheck.Api/Authorization/AuthService.cs similarity index 99% rename from src/api/MicCheck.Api/Auth/AuthService.cs rename to src/api/MicCheck.Api/Authorization/AuthService.cs index 983d038..714ced0 100644 --- a/src/api/MicCheck.Api/Auth/AuthService.cs +++ b/src/api/MicCheck.Api/Authorization/AuthService.cs @@ -5,7 +5,7 @@ using MicCheck.Api.Users; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; -namespace MicCheck.Api.Auth; +namespace MicCheck.Api.Authorization; public class AuthService( MicCheckDbContext db, diff --git a/src/api/MicCheck.Api/Auth/ITokenService.cs b/src/api/MicCheck.Api/Authorization/ITokenService.cs similarity index 73% rename from src/api/MicCheck.Api/Auth/ITokenService.cs rename to src/api/MicCheck.Api/Authorization/ITokenService.cs index 6c40fcc..bbbfba1 100644 --- a/src/api/MicCheck.Api/Auth/ITokenService.cs +++ b/src/api/MicCheck.Api/Authorization/ITokenService.cs @@ -1,6 +1,6 @@ using MicCheck.Api.Users; -namespace MicCheck.Api.Auth; +namespace MicCheck.Api.Authorization; public interface ITokenService { diff --git a/src/api/MicCheck.Api/Auth/LoginRequest.cs b/src/api/MicCheck.Api/Authorization/LoginRequest.cs similarity index 61% rename from src/api/MicCheck.Api/Auth/LoginRequest.cs rename to src/api/MicCheck.Api/Authorization/LoginRequest.cs index 56d7ed5..f1fbd85 100644 --- a/src/api/MicCheck.Api/Auth/LoginRequest.cs +++ b/src/api/MicCheck.Api/Authorization/LoginRequest.cs @@ -1,3 +1,3 @@ -namespace MicCheck.Api.Auth; +namespace MicCheck.Api.Authorization; public record LoginRequest(string Email, string Password); diff --git a/src/api/MicCheck.Api/Auth/LoginResponse.cs b/src/api/MicCheck.Api/Authorization/LoginResponse.cs similarity index 70% rename from src/api/MicCheck.Api/Auth/LoginResponse.cs rename to src/api/MicCheck.Api/Authorization/LoginResponse.cs index 6d2d43b..e13a8d8 100644 --- a/src/api/MicCheck.Api/Auth/LoginResponse.cs +++ b/src/api/MicCheck.Api/Authorization/LoginResponse.cs @@ -1,3 +1,3 @@ -namespace MicCheck.Api.Auth; +namespace MicCheck.Api.Authorization; public record LoginResponse(string AccessToken, string RefreshToken, DateTime ExpiresAt); diff --git a/src/api/MicCheck.Api/Auth/LogoutRequest.cs b/src/api/MicCheck.Api/Authorization/LogoutRequest.cs similarity index 53% rename from src/api/MicCheck.Api/Auth/LogoutRequest.cs rename to src/api/MicCheck.Api/Authorization/LogoutRequest.cs index c1ab20a..b379da0 100644 --- a/src/api/MicCheck.Api/Auth/LogoutRequest.cs +++ b/src/api/MicCheck.Api/Authorization/LogoutRequest.cs @@ -1,3 +1,3 @@ -namespace MicCheck.Api.Auth; +namespace MicCheck.Api.Authorization; public record LogoutRequest(string Token); diff --git a/src/api/MicCheck.Api/Auth/RefreshRequest.cs b/src/api/MicCheck.Api/Authorization/RefreshRequest.cs similarity index 54% rename from src/api/MicCheck.Api/Auth/RefreshRequest.cs rename to src/api/MicCheck.Api/Authorization/RefreshRequest.cs index bfe4bbd..94aa24e 100644 --- a/src/api/MicCheck.Api/Auth/RefreshRequest.cs +++ b/src/api/MicCheck.Api/Authorization/RefreshRequest.cs @@ -1,3 +1,3 @@ -namespace MicCheck.Api.Auth; +namespace MicCheck.Api.Authorization; public record RefreshRequest(string Token); diff --git a/src/api/MicCheck.Api/Auth/RegisterRequest.cs b/src/api/MicCheck.Api/Authorization/RegisterRequest.cs similarity index 79% rename from src/api/MicCheck.Api/Auth/RegisterRequest.cs rename to src/api/MicCheck.Api/Authorization/RegisterRequest.cs index e8886f4..58dcffe 100644 --- a/src/api/MicCheck.Api/Auth/RegisterRequest.cs +++ b/src/api/MicCheck.Api/Authorization/RegisterRequest.cs @@ -1,4 +1,4 @@ -namespace MicCheck.Api.Auth; +namespace MicCheck.Api.Authorization; public record RegisterRequest( string Email, diff --git a/src/api/MicCheck.Api/Auth/TokenResponse.cs b/src/api/MicCheck.Api/Authorization/TokenResponse.cs similarity index 62% rename from src/api/MicCheck.Api/Auth/TokenResponse.cs rename to src/api/MicCheck.Api/Authorization/TokenResponse.cs index f244625..0439497 100644 --- a/src/api/MicCheck.Api/Auth/TokenResponse.cs +++ b/src/api/MicCheck.Api/Authorization/TokenResponse.cs @@ -1,3 +1,3 @@ -namespace MicCheck.Api.Auth; +namespace MicCheck.Api.Authorization; public record TokenResponse(string Token, DateTime ExpiresAt); diff --git a/src/api/MicCheck.Api/Auth/TokenService.cs b/src/api/MicCheck.Api/Authorization/TokenService.cs similarity index 98% rename from src/api/MicCheck.Api/Auth/TokenService.cs rename to src/api/MicCheck.Api/Authorization/TokenService.cs index bbe883b..089fb53 100644 --- a/src/api/MicCheck.Api/Auth/TokenService.cs +++ b/src/api/MicCheck.Api/Authorization/TokenService.cs @@ -4,7 +4,7 @@ using System.Text; using MicCheck.Api.Users; using Microsoft.IdentityModel.Tokens; -namespace MicCheck.Api.Auth; +namespace MicCheck.Api.Authorization; public class TokenService(IConfiguration configuration) : ITokenService { diff --git a/src/api/MicCheck.Api/Environments/EnvironmentsController.cs b/src/api/MicCheck.Api/Environments/EnvironmentsController.cs index 1bd3d6c..a290479 100644 --- a/src/api/MicCheck.Api/Environments/EnvironmentsController.cs +++ b/src/api/MicCheck.Api/Environments/EnvironmentsController.cs @@ -106,6 +106,20 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho return Ok(WebhookResponse.From(updated)); } + [HttpGet("{apiKey}/webhooks/{id}/deliveries")] + public async Task>> ListWebhookDeliveries( + 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(); + + var logs = await webhookService.ListDeliveriesAsync(id, ct); + return Ok(logs.Select(WebhookDeliveryLogResponse.From).ToList()); + } + [HttpDelete("{apiKey}/webhooks/{id}")] public async Task DeleteWebhook(string apiKey, int id, CancellationToken ct) { @@ -128,7 +142,7 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); if (environment is null) return NotFound(); - var logs = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, ct); + var (_, logs) = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, new Audit.AuditLogFilter(), ct); return Ok(logs.Select(Audit.AuditLogResponse.From).ToList()); } diff --git a/src/api/MicCheck.Api/Features/FeatureService.cs b/src/api/MicCheck.Api/Features/FeatureService.cs index 5b1e805..2113444 100644 --- a/src/api/MicCheck.Api/Features/FeatureService.cs +++ b/src/api/MicCheck.Api/Features/FeatureService.cs @@ -1,11 +1,12 @@ using MicCheck.Api.Audit; using MicCheck.Api.Common; using MicCheck.Api.Data; +using MicCheck.Api.Webhooks; using Microsoft.EntityFrameworkCore; namespace MicCheck.Api.Features; -public class FeatureService(MicCheckDbContext db, AuditService auditService) +public class FeatureService(MicCheckDbContext db, AuditService auditService, WebhookQueue webhookQueue) { private const int MaxFeaturesPerProject = 400; @@ -67,8 +68,11 @@ public class FeatureService(MicCheckDbContext db, AuditService auditService) 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); + await auditService.RecordAsync( + "Feature", feature.Id.ToString(), "created", + project.OrganizationId, projectId, + after: new { feature.Name, Type = feature.Type.ToString(), feature.InitialValue, feature.Description }, + ct: ct); return feature; } @@ -79,14 +83,20 @@ public class FeatureService(MicCheckDbContext db, AuditService auditService) var feature = await db.Features.FirstOrDefaultAsync(f => f.Id == id, ct) ?? throw new KeyNotFoundException($"Feature {id} not found."); + var before = new { feature.Name, feature.Description }; + 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); + await auditService.RecordAsync( + "Feature", feature.Id.ToString(), "updated", + project.OrganizationId, feature.ProjectId, + before: before, + after: new { feature.Name, feature.Description }, + ct: ct); return feature; } @@ -96,7 +106,33 @@ public class FeatureService(MicCheckDbContext db, AuditService auditService) var feature = await db.Features.FirstOrDefaultAsync(f => f.Id == id, ct); if (feature is null) return; + var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == feature.ProjectId, ct); + db.Features.Remove(feature); await db.SaveChangesAsync(ct); + + if (project is not null) + { + await auditService.RecordAsync( + "Feature", id.ToString(), "deleted", + project.OrganizationId, feature.ProjectId, + before: new { feature.Name, Type = feature.Type.ToString() }, + ct: ct); + + var environments = await db.Environments.Where(e => e.ProjectId == feature.ProjectId).ToListAsync(ct); + foreach (var env in environments) + { + await webhookQueue.EnqueueAsync(new WebhookEvent + { + EventType = WebhookEventTypes.FlagDeleted, + EnvironmentId = env.Id, + OrganizationId = project.OrganizationId, + Data = new FlagDeletedData( + null, + DateTimeOffset.UtcNow, + new FeatureSummary(feature.Id, feature.Name)) + }); + } + } } } diff --git a/src/api/MicCheck.Api/Features/FeatureStateService.cs b/src/api/MicCheck.Api/Features/FeatureStateService.cs index 5f40b7b..a684952 100644 --- a/src/api/MicCheck.Api/Features/FeatureStateService.cs +++ b/src/api/MicCheck.Api/Features/FeatureStateService.cs @@ -1,9 +1,11 @@ +using MicCheck.Api.Audit; using MicCheck.Api.Data; +using MicCheck.Api.Webhooks; using Microsoft.EntityFrameworkCore; namespace MicCheck.Api.Features; -public class FeatureStateService(MicCheckDbContext db) +public class FeatureStateService(MicCheckDbContext db, WebhookQueue webhookQueue, AuditService auditService) { public async Task> ListByEnvironmentAsync(int environmentId, CancellationToken ct = default) { @@ -23,11 +25,15 @@ public class FeatureStateService(MicCheckDbContext db) var state = await db.FeatureStates.FirstOrDefaultAsync(fs => fs.Id == id, ct) ?? throw new KeyNotFoundException($"FeatureState {id} not found."); + var before = SnapshotState(state); + state.Enabled = enabled; state.Value = value; state.UpdatedAt = DateTimeOffset.UtcNow; state.Version++; await db.SaveChangesAsync(ct); + + await DispatchFlagUpdatedAsync(state, before, ct); return state; } @@ -36,11 +42,60 @@ public class FeatureStateService(MicCheckDbContext db) var state = await db.FeatureStates.FirstOrDefaultAsync(fs => fs.Id == id, ct) ?? throw new KeyNotFoundException($"FeatureState {id} not found."); + var before = SnapshotState(state); + 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); + + await DispatchFlagUpdatedAsync(state, before, ct); return state; } + + private async Task DispatchFlagUpdatedAsync(FeatureState state, FeatureStateSnapshot before, CancellationToken ct) + { + var feature = await db.Features.FirstOrDefaultAsync(f => f.Id == state.FeatureId, ct); + var environment = await db.Environments.FirstOrDefaultAsync(e => e.Id == state.EnvironmentId, ct); + var project = environment is not null + ? await db.Projects.FirstOrDefaultAsync(p => p.Id == environment.ProjectId, ct) + : null; + + var featureSummary = feature is not null + ? new FeatureSummary(feature.Id, feature.Name) + : new FeatureSummary(state.FeatureId, string.Empty); + + var environmentSummary = environment is not null + ? new EnvironmentSummary(environment.Id, environment.Name) + : new EnvironmentSummary(state.EnvironmentId, string.Empty); + + var after = new FlagStateSnapshot(state.Id, state.Enabled, state.Value, featureSummary, environmentSummary); + var previousSnapshot = new FlagStateSnapshot(state.Id, before.Enabled, before.Value, featureSummary, environmentSummary); + + var data = new FlagUpdatedData(null, DateTimeOffset.UtcNow, after, previousSnapshot); + + await webhookQueue.EnqueueAsync(new WebhookEvent + { + EventType = WebhookEventTypes.FlagUpdated, + EnvironmentId = state.EnvironmentId, + OrganizationId = project?.OrganizationId ?? 0, + Data = data + }); + + if (project is not null) + { + await auditService.RecordAsync( + "FeatureState", state.Id.ToString(), "updated", + project.OrganizationId, environment!.ProjectId, state.EnvironmentId, + before: new { before.Enabled, before.Value }, + after: new { state.Enabled, state.Value }, + ct: ct); + } + } + + private static FeatureStateSnapshot SnapshotState(FeatureState state) => + new(state.Enabled, state.Value); + + private record FeatureStateSnapshot(bool Enabled, string? Value); } diff --git a/src/api/MicCheck.Api/Migrations/20260408214714_InitialCreate.Designer.cs b/src/api/MicCheck.Api/Migrations/20260408214714_InitialCreate.Designer.cs new file mode 100644 index 0000000..a271252 --- /dev/null +++ b/src/api/MicCheck.Api/Migrations/20260408214714_InitialCreate.Designer.cs @@ -0,0 +1,895 @@ +// +using System; +using MicCheck.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MicCheck.Api.Migrations +{ + [DbContext(typeof(MicCheckDbContext))] + [Migration("20260408214714_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MicCheck.Api.ApiKeys.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationId") + .HasColumnType("integer"); + + b.Property("Prefix") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.HasIndex("OrganizationId"); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("MicCheck.Api.Audit.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ActorUserId") + .HasColumnType("integer"); + + b.Property("Changes") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnvironmentId") + .HasColumnType("integer"); + + b.Property("OrganizationId") + .HasColumnType("integer"); + + b.Property("ProjectId") + .HasColumnType("integer"); + + b.Property("ResourceId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ResourceType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "CreatedAt"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("MicCheck.Api.Authorization.UserProjectPermission", b => + { + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("ProjectId") + .HasColumnType("integer"); + + b.Property("IsAdmin") + .HasColumnType("boolean"); + + b.Property("Permissions") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("UserId", "ProjectId"); + + b.ToTable("UserProjectPermissions"); + }); + + modelBuilder.Entity("MicCheck.Api.Environments.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ProjectId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ApiKey") + .IsUnique(); + + b.HasIndex("ProjectId"); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("MicCheck.Api.Features.Feature", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultEnabled") + .HasColumnType("boolean"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("InitialValue") + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("ProjectId") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId", "Name") + .IsUnique(); + + b.ToTable("Features"); + }); + + modelBuilder.Entity("MicCheck.Api.Features.FeatureSegment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("EnvironmentId") + .HasColumnType("integer"); + + b.Property("FeatureId") + .HasColumnType("integer"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("SegmentId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("FeatureSegments"); + }); + + modelBuilder.Entity("MicCheck.Api.Features.FeatureState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("EnvironmentId") + .HasColumnType("integer"); + + b.Property("FeatureId") + .HasColumnType("integer"); + + b.Property("FeatureSegmentId") + .HasColumnType("integer"); + + b.Property("IdentityId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("FeatureSegmentId") + .IsUnique(); + + b.HasIndex("IdentityId"); + + b.HasIndex("FeatureId", "EnvironmentId", "IdentityId") + .IsUnique(); + + b.ToTable("FeatureStates"); + }); + + modelBuilder.Entity("MicCheck.Api.Features.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Color") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ProjectId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("MicCheck.Api.Identities.Identity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnvironmentId") + .HasColumnType("integer"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId", "Identifier") + .IsUnique(); + + b.ToTable("Identities"); + }); + + modelBuilder.Entity("MicCheck.Api.Identities.IdentityTrait", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("IdentityId") + .HasColumnType("integer"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("ValueType") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("IdentityId", "Key") + .IsUnique(); + + b.ToTable("IdentityTraits"); + }); + + modelBuilder.Entity("MicCheck.Api.Organizations.Organization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.ToTable("Organizations"); + }); + + modelBuilder.Entity("MicCheck.Api.Organizations.OrganizationUser", b => + { + b.Property("OrganizationId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("OrganizationId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("OrganizationUsers"); + }); + + modelBuilder.Entity("MicCheck.Api.Projects.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HideDisabledFlags") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Projects"); + }); + + modelBuilder.Entity("MicCheck.Api.Segments.Segment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ProjectId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("Segments"); + }); + + modelBuilder.Entity("MicCheck.Api.Segments.SegmentCondition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Operator") + .HasColumnType("integer"); + + b.Property("Property") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RuleId") + .HasColumnType("integer"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.HasKey("Id"); + + b.HasIndex("RuleId"); + + b.ToTable("SegmentConditions"); + }); + + modelBuilder.Entity("MicCheck.Api.Segments.SegmentRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ParentRuleId") + .HasColumnType("integer"); + + b.Property("SegmentId") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ParentRuleId"); + + b.HasIndex("SegmentId"); + + b.ToTable("SegmentRules"); + }); + + modelBuilder.Entity("MicCheck.Api.Users.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("MicCheck.Api.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsTwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("MicCheck.Api.Webhooks.Webhook", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("EnvironmentId") + .HasColumnType("integer"); + + b.Property("OrganizationId") + .HasColumnType("integer"); + + b.Property("Scope") + .HasColumnType("integer"); + + b.Property("Secret") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("Webhooks"); + }); + + modelBuilder.Entity("MicCheck.Api.Webhooks.WebhookDeliveryLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AttemptedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("ErrorMessage") + .HasColumnType("text"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("ResponseBody") + .HasColumnType("text"); + + b.Property("ResponseStatusCode") + .HasColumnType("integer"); + + b.Property("Success") + .HasColumnType("boolean"); + + b.Property("WebhookId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("WebhookId"); + + b.ToTable("WebhookDeliveryLogs"); + }); + + modelBuilder.Entity("MicCheck.Api.ApiKeys.ApiKey", b => + { + b.HasOne("MicCheck.Api.Organizations.Organization", null) + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MicCheck.Api.Environments.Environment", b => + { + b.HasOne("MicCheck.Api.Projects.Project", null) + .WithMany("Environments") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MicCheck.Api.Features.Feature", b => + { + b.HasOne("MicCheck.Api.Projects.Project", null) + .WithMany("Features") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MicCheck.Api.Features.FeatureState", b => + { + b.HasOne("MicCheck.Api.Environments.Environment", null) + .WithMany("FeatureStates") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MicCheck.Api.Features.Feature", null) + .WithMany("FeatureStates") + .HasForeignKey("FeatureId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MicCheck.Api.Features.FeatureSegment", null) + .WithOne("FeatureState") + .HasForeignKey("MicCheck.Api.Features.FeatureState", "FeatureSegmentId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("MicCheck.Api.Identities.Identity", null) + .WithMany("FeatureStateOverrides") + .HasForeignKey("IdentityId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("MicCheck.Api.Features.Tag", b => + { + b.HasOne("MicCheck.Api.Features.Feature", null) + .WithMany("Tags") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + }); + + modelBuilder.Entity("MicCheck.Api.Identities.IdentityTrait", b => + { + b.HasOne("MicCheck.Api.Identities.Identity", null) + .WithMany("Traits") + .HasForeignKey("IdentityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MicCheck.Api.Organizations.OrganizationUser", b => + { + b.HasOne("MicCheck.Api.Organizations.Organization", null) + .WithMany("Members") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MicCheck.Api.Users.User", null) + .WithMany("Organizations") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MicCheck.Api.Projects.Project", b => + { + b.HasOne("MicCheck.Api.Organizations.Organization", null) + .WithMany("Projects") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MicCheck.Api.Segments.Segment", b => + { + b.HasOne("MicCheck.Api.Projects.Project", null) + .WithMany("Segments") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MicCheck.Api.Segments.SegmentCondition", b => + { + b.HasOne("MicCheck.Api.Segments.SegmentRule", null) + .WithMany("Conditions") + .HasForeignKey("RuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MicCheck.Api.Segments.SegmentRule", b => + { + b.HasOne("MicCheck.Api.Segments.SegmentRule", null) + .WithMany("ChildRules") + .HasForeignKey("ParentRuleId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("MicCheck.Api.Segments.Segment", null) + .WithMany("Rules") + .HasForeignKey("SegmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MicCheck.Api.Users.RefreshToken", b => + { + b.HasOne("MicCheck.Api.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("MicCheck.Api.Environments.Environment", b => + { + b.Navigation("FeatureStates"); + }); + + modelBuilder.Entity("MicCheck.Api.Features.Feature", b => + { + b.Navigation("FeatureStates"); + + b.Navigation("Tags"); + }); + + modelBuilder.Entity("MicCheck.Api.Features.FeatureSegment", b => + { + b.Navigation("FeatureState"); + }); + + modelBuilder.Entity("MicCheck.Api.Identities.Identity", b => + { + b.Navigation("FeatureStateOverrides"); + + b.Navigation("Traits"); + }); + + modelBuilder.Entity("MicCheck.Api.Organizations.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Members"); + + b.Navigation("Projects"); + }); + + modelBuilder.Entity("MicCheck.Api.Projects.Project", b => + { + b.Navigation("Environments"); + + b.Navigation("Features"); + + b.Navigation("Segments"); + }); + + modelBuilder.Entity("MicCheck.Api.Segments.Segment", b => + { + b.Navigation("Rules"); + }); + + modelBuilder.Entity("MicCheck.Api.Segments.SegmentRule", b => + { + b.Navigation("ChildRules"); + + b.Navigation("Conditions"); + }); + + modelBuilder.Entity("MicCheck.Api.Users.User", b => + { + b.Navigation("Organizations"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/api/MicCheck.Api/Migrations/20260408214714_InitialCreate.cs b/src/api/MicCheck.Api/Migrations/20260408214714_InitialCreate.cs new file mode 100644 index 0000000..1686926 --- /dev/null +++ b/src/api/MicCheck.Api/Migrations/20260408214714_InitialCreate.cs @@ -0,0 +1,647 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MicCheck.Api.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AuditLogs", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + ResourceType = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + ResourceId = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Action = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + Changes = table.Column(type: "text", nullable: true), + OrganizationId = table.Column(type: "integer", nullable: false), + ProjectId = table.Column(type: "integer", nullable: true), + EnvironmentId = table.Column(type: "integer", nullable: true), + ActorUserId = table.Column(type: "integer", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AuditLogs", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "FeatureSegments", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + FeatureId = table.Column(type: "integer", nullable: false), + SegmentId = table.Column(type: "integer", nullable: false), + EnvironmentId = table.Column(type: "integer", nullable: false), + Priority = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_FeatureSegments", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Identities", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Identifier = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: false), + EnvironmentId = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Identities", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Organizations", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Organizations", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "UserProjectPermissions", + columns: table => new + { + UserId = table.Column(type: "integer", nullable: false), + ProjectId = table.Column(type: "integer", nullable: false), + Permissions = table.Column(type: "text", nullable: false), + IsAdmin = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserProjectPermissions", x => new { x.UserId, x.ProjectId }); + }); + + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Email = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + PasswordHash = table.Column(type: "text", nullable: false), + FirstName = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + LastName = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + IsActive = table.Column(type: "boolean", nullable: false), + IsTwoFactorEnabled = table.Column(type: "boolean", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + LastLoginAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "WebhookDeliveryLogs", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + WebhookId = table.Column(type: "integer", nullable: false), + EventType = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + PayloadJson = table.Column(type: "text", nullable: false), + ResponseStatusCode = table.Column(type: "integer", nullable: true), + ResponseBody = table.Column(type: "text", nullable: true), + Success = table.Column(type: "boolean", nullable: false), + ErrorMessage = table.Column(type: "text", nullable: true), + AttemptedAt = table.Column(type: "timestamp with time zone", nullable: false), + Duration = table.Column(type: "interval", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_WebhookDeliveryLogs", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Webhooks", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Url = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + Secret = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + Scope = table.Column(type: "integer", nullable: false), + EnvironmentId = table.Column(type: "integer", nullable: true), + OrganizationId = table.Column(type: "integer", nullable: true), + Enabled = table.Column(type: "boolean", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Webhooks", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "IdentityTraits", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + IdentityId = table.Column(type: "integer", nullable: false), + Key = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Value = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: false), + ValueType = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_IdentityTraits", x => x.Id); + table.ForeignKey( + name: "FK_IdentityTraits_Identities_IdentityId", + column: x => x.IdentityId, + principalTable: "Identities", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ApiKeys", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Key = table.Column(type: "character varying(512)", maxLength: 512, nullable: false), + Prefix = table.Column(type: "character varying(8)", maxLength: 8, nullable: false), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + OrganizationId = table.Column(type: "integer", nullable: false), + IsActive = table.Column(type: "boolean", nullable: false), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ApiKeys", x => x.Id); + table.ForeignKey( + name: "FK_ApiKeys_Organizations_OrganizationId", + column: x => x.OrganizationId, + principalTable: "Organizations", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Projects", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + OrganizationId = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + HideDisabledFlags = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Projects", x => x.Id); + table.ForeignKey( + name: "FK_Projects_Organizations_OrganizationId", + column: x => x.OrganizationId, + principalTable: "Organizations", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "OrganizationUsers", + columns: table => new + { + OrganizationId = table.Column(type: "integer", nullable: false), + UserId = table.Column(type: "integer", nullable: false), + Role = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_OrganizationUsers", x => new { x.OrganizationId, x.UserId }); + table.ForeignKey( + name: "FK_OrganizationUsers_Organizations_OrganizationId", + column: x => x.OrganizationId, + principalTable: "Organizations", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_OrganizationUsers_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "RefreshTokens", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Token = table.Column(type: "character varying(512)", maxLength: 512, nullable: false), + UserId = table.Column(type: "integer", nullable: false), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: false), + IsRevoked = table.Column(type: "boolean", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_RefreshTokens", x => x.Id); + table.ForeignKey( + name: "FK_RefreshTokens_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Environments", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + ApiKey = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + ProjectId = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Environments", x => x.Id); + table.ForeignKey( + name: "FK_Environments_Projects_ProjectId", + column: x => x.ProjectId, + principalTable: "Projects", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Features", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "character varying(150)", maxLength: 150, nullable: false), + Type = table.Column(type: "integer", nullable: false), + InitialValue = table.Column(type: "character varying(20000)", maxLength: 20000, nullable: true), + Description = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true), + DefaultEnabled = table.Column(type: "boolean", nullable: false), + ProjectId = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Features", x => x.Id); + table.ForeignKey( + name: "FK_Features_Projects_ProjectId", + column: x => x.ProjectId, + principalTable: "Projects", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Segments", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + ProjectId = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Segments", x => x.Id); + table.ForeignKey( + name: "FK_Segments_Projects_ProjectId", + column: x => x.ProjectId, + principalTable: "Projects", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "FeatureStates", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + FeatureId = table.Column(type: "integer", nullable: false), + EnvironmentId = table.Column(type: "integer", nullable: false), + IdentityId = table.Column(type: "integer", nullable: true), + Enabled = table.Column(type: "boolean", nullable: false), + Value = table.Column(type: "character varying(20000)", maxLength: 20000, nullable: true), + FeatureSegmentId = table.Column(type: "integer", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false), + Version = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_FeatureStates", x => x.Id); + table.ForeignKey( + name: "FK_FeatureStates_Environments_EnvironmentId", + column: x => x.EnvironmentId, + principalTable: "Environments", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_FeatureStates_FeatureSegments_FeatureSegmentId", + column: x => x.FeatureSegmentId, + principalTable: "FeatureSegments", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_FeatureStates_Features_FeatureId", + column: x => x.FeatureId, + principalTable: "Features", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_FeatureStates_Identities_IdentityId", + column: x => x.IdentityId, + principalTable: "Identities", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Tags", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Label = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Color = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + ProjectId = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Tags", x => x.Id); + table.ForeignKey( + name: "FK_Tags_Features_ProjectId", + column: x => x.ProjectId, + principalTable: "Features", + principalColumn: "Id"); + }); + + migrationBuilder.CreateTable( + name: "SegmentRules", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + SegmentId = table.Column(type: "integer", nullable: false), + ParentRuleId = table.Column(type: "integer", nullable: true), + Type = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SegmentRules", x => x.Id); + table.ForeignKey( + name: "FK_SegmentRules_SegmentRules_ParentRuleId", + column: x => x.ParentRuleId, + principalTable: "SegmentRules", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_SegmentRules_Segments_SegmentId", + column: x => x.SegmentId, + principalTable: "Segments", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "SegmentConditions", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + RuleId = table.Column(type: "integer", nullable: false), + Property = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Operator = table.Column(type: "integer", nullable: false), + Value = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SegmentConditions", x => x.Id); + table.ForeignKey( + name: "FK_SegmentConditions_SegmentRules_RuleId", + column: x => x.RuleId, + principalTable: "SegmentRules", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ApiKeys_Key", + table: "ApiKeys", + column: "Key", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ApiKeys_OrganizationId", + table: "ApiKeys", + column: "OrganizationId"); + + migrationBuilder.CreateIndex( + name: "IX_AuditLogs_OrganizationId_CreatedAt", + table: "AuditLogs", + columns: new[] { "OrganizationId", "CreatedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_Environments_ApiKey", + table: "Environments", + column: "ApiKey", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Environments_ProjectId", + table: "Environments", + column: "ProjectId"); + + migrationBuilder.CreateIndex( + name: "IX_Features_ProjectId_Name", + table: "Features", + columns: new[] { "ProjectId", "Name" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_FeatureStates_EnvironmentId", + table: "FeatureStates", + column: "EnvironmentId"); + + migrationBuilder.CreateIndex( + name: "IX_FeatureStates_FeatureId_EnvironmentId_IdentityId", + table: "FeatureStates", + columns: new[] { "FeatureId", "EnvironmentId", "IdentityId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_FeatureStates_FeatureSegmentId", + table: "FeatureStates", + column: "FeatureSegmentId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_FeatureStates_IdentityId", + table: "FeatureStates", + column: "IdentityId"); + + migrationBuilder.CreateIndex( + name: "IX_Identities_EnvironmentId_Identifier", + table: "Identities", + columns: new[] { "EnvironmentId", "Identifier" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_IdentityTraits_IdentityId_Key", + table: "IdentityTraits", + columns: new[] { "IdentityId", "Key" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_OrganizationUsers_UserId", + table: "OrganizationUsers", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_Projects_OrganizationId", + table: "Projects", + column: "OrganizationId"); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_Token", + table: "RefreshTokens", + column: "Token", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_UserId", + table: "RefreshTokens", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_SegmentConditions_RuleId", + table: "SegmentConditions", + column: "RuleId"); + + migrationBuilder.CreateIndex( + name: "IX_SegmentRules_ParentRuleId", + table: "SegmentRules", + column: "ParentRuleId"); + + migrationBuilder.CreateIndex( + name: "IX_SegmentRules_SegmentId", + table: "SegmentRules", + column: "SegmentId"); + + migrationBuilder.CreateIndex( + name: "IX_Segments_ProjectId", + table: "Segments", + column: "ProjectId"); + + migrationBuilder.CreateIndex( + name: "IX_Tags_ProjectId", + table: "Tags", + column: "ProjectId"); + + migrationBuilder.CreateIndex( + name: "IX_Users_Email", + table: "Users", + column: "Email", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_WebhookDeliveryLogs_WebhookId", + table: "WebhookDeliveryLogs", + column: "WebhookId"); + + migrationBuilder.CreateIndex( + name: "IX_Webhooks_EnvironmentId", + table: "Webhooks", + column: "EnvironmentId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ApiKeys"); + + migrationBuilder.DropTable( + name: "AuditLogs"); + + migrationBuilder.DropTable( + name: "FeatureStates"); + + migrationBuilder.DropTable( + name: "IdentityTraits"); + + migrationBuilder.DropTable( + name: "OrganizationUsers"); + + migrationBuilder.DropTable( + name: "RefreshTokens"); + + migrationBuilder.DropTable( + name: "SegmentConditions"); + + migrationBuilder.DropTable( + name: "Tags"); + + migrationBuilder.DropTable( + name: "UserProjectPermissions"); + + migrationBuilder.DropTable( + name: "WebhookDeliveryLogs"); + + migrationBuilder.DropTable( + name: "Webhooks"); + + migrationBuilder.DropTable( + name: "Environments"); + + migrationBuilder.DropTable( + name: "FeatureSegments"); + + migrationBuilder.DropTable( + name: "Identities"); + + migrationBuilder.DropTable( + name: "Users"); + + migrationBuilder.DropTable( + name: "SegmentRules"); + + migrationBuilder.DropTable( + name: "Features"); + + migrationBuilder.DropTable( + name: "Segments"); + + migrationBuilder.DropTable( + name: "Projects"); + + migrationBuilder.DropTable( + name: "Organizations"); + } + } +} diff --git a/src/api/MicCheck.Api/Migrations/20260410175950_AddWebhookDeliveryAttemptNumber.Designer.cs b/src/api/MicCheck.Api/Migrations/20260410175950_AddWebhookDeliveryAttemptNumber.Designer.cs new file mode 100644 index 0000000..aefaf0e --- /dev/null +++ b/src/api/MicCheck.Api/Migrations/20260410175950_AddWebhookDeliveryAttemptNumber.Designer.cs @@ -0,0 +1,898 @@ +// +using System; +using MicCheck.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MicCheck.Api.Migrations +{ + [DbContext(typeof(MicCheckDbContext))] + [Migration("20260410175950_AddWebhookDeliveryAttemptNumber")] + partial class AddWebhookDeliveryAttemptNumber + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MicCheck.Api.ApiKeys.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationId") + .HasColumnType("integer"); + + b.Property("Prefix") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.HasIndex("OrganizationId"); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("MicCheck.Api.Audit.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ActorUserId") + .HasColumnType("integer"); + + b.Property("Changes") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnvironmentId") + .HasColumnType("integer"); + + b.Property("OrganizationId") + .HasColumnType("integer"); + + b.Property("ProjectId") + .HasColumnType("integer"); + + b.Property("ResourceId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ResourceType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "CreatedAt"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("MicCheck.Api.Authorization.UserProjectPermission", b => + { + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("ProjectId") + .HasColumnType("integer"); + + b.Property("IsAdmin") + .HasColumnType("boolean"); + + b.Property("Permissions") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("UserId", "ProjectId"); + + b.ToTable("UserProjectPermissions"); + }); + + modelBuilder.Entity("MicCheck.Api.Environments.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ProjectId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ApiKey") + .IsUnique(); + + b.HasIndex("ProjectId"); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("MicCheck.Api.Features.Feature", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultEnabled") + .HasColumnType("boolean"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("InitialValue") + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("ProjectId") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId", "Name") + .IsUnique(); + + b.ToTable("Features"); + }); + + modelBuilder.Entity("MicCheck.Api.Features.FeatureSegment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("EnvironmentId") + .HasColumnType("integer"); + + b.Property("FeatureId") + .HasColumnType("integer"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("SegmentId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("FeatureSegments"); + }); + + modelBuilder.Entity("MicCheck.Api.Features.FeatureState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("EnvironmentId") + .HasColumnType("integer"); + + b.Property("FeatureId") + .HasColumnType("integer"); + + b.Property("FeatureSegmentId") + .HasColumnType("integer"); + + b.Property("IdentityId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("FeatureSegmentId") + .IsUnique(); + + b.HasIndex("IdentityId"); + + b.HasIndex("FeatureId", "EnvironmentId", "IdentityId") + .IsUnique(); + + b.ToTable("FeatureStates"); + }); + + modelBuilder.Entity("MicCheck.Api.Features.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Color") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ProjectId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("MicCheck.Api.Identities.Identity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnvironmentId") + .HasColumnType("integer"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId", "Identifier") + .IsUnique(); + + b.ToTable("Identities"); + }); + + modelBuilder.Entity("MicCheck.Api.Identities.IdentityTrait", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("IdentityId") + .HasColumnType("integer"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("ValueType") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("IdentityId", "Key") + .IsUnique(); + + b.ToTable("IdentityTraits"); + }); + + modelBuilder.Entity("MicCheck.Api.Organizations.Organization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.ToTable("Organizations"); + }); + + modelBuilder.Entity("MicCheck.Api.Organizations.OrganizationUser", b => + { + b.Property("OrganizationId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("OrganizationId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("OrganizationUsers"); + }); + + modelBuilder.Entity("MicCheck.Api.Projects.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HideDisabledFlags") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Projects"); + }); + + modelBuilder.Entity("MicCheck.Api.Segments.Segment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ProjectId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("Segments"); + }); + + modelBuilder.Entity("MicCheck.Api.Segments.SegmentCondition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Operator") + .HasColumnType("integer"); + + b.Property("Property") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RuleId") + .HasColumnType("integer"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.HasKey("Id"); + + b.HasIndex("RuleId"); + + b.ToTable("SegmentConditions"); + }); + + modelBuilder.Entity("MicCheck.Api.Segments.SegmentRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ParentRuleId") + .HasColumnType("integer"); + + b.Property("SegmentId") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ParentRuleId"); + + b.HasIndex("SegmentId"); + + b.ToTable("SegmentRules"); + }); + + modelBuilder.Entity("MicCheck.Api.Users.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("MicCheck.Api.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsTwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("MicCheck.Api.Webhooks.Webhook", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("EnvironmentId") + .HasColumnType("integer"); + + b.Property("OrganizationId") + .HasColumnType("integer"); + + b.Property("Scope") + .HasColumnType("integer"); + + b.Property("Secret") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("Webhooks"); + }); + + modelBuilder.Entity("MicCheck.Api.Webhooks.WebhookDeliveryLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AttemptNumber") + .HasColumnType("integer"); + + b.Property("AttemptedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("ErrorMessage") + .HasColumnType("text"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("ResponseBody") + .HasColumnType("text"); + + b.Property("ResponseStatusCode") + .HasColumnType("integer"); + + b.Property("Success") + .HasColumnType("boolean"); + + b.Property("WebhookId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("WebhookId"); + + b.ToTable("WebhookDeliveryLogs"); + }); + + modelBuilder.Entity("MicCheck.Api.ApiKeys.ApiKey", b => + { + b.HasOne("MicCheck.Api.Organizations.Organization", null) + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MicCheck.Api.Environments.Environment", b => + { + b.HasOne("MicCheck.Api.Projects.Project", null) + .WithMany("Environments") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MicCheck.Api.Features.Feature", b => + { + b.HasOne("MicCheck.Api.Projects.Project", null) + .WithMany("Features") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MicCheck.Api.Features.FeatureState", b => + { + b.HasOne("MicCheck.Api.Environments.Environment", null) + .WithMany("FeatureStates") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MicCheck.Api.Features.Feature", null) + .WithMany("FeatureStates") + .HasForeignKey("FeatureId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MicCheck.Api.Features.FeatureSegment", null) + .WithOne("FeatureState") + .HasForeignKey("MicCheck.Api.Features.FeatureState", "FeatureSegmentId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("MicCheck.Api.Identities.Identity", null) + .WithMany("FeatureStateOverrides") + .HasForeignKey("IdentityId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("MicCheck.Api.Features.Tag", b => + { + b.HasOne("MicCheck.Api.Features.Feature", null) + .WithMany("Tags") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + }); + + modelBuilder.Entity("MicCheck.Api.Identities.IdentityTrait", b => + { + b.HasOne("MicCheck.Api.Identities.Identity", null) + .WithMany("Traits") + .HasForeignKey("IdentityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MicCheck.Api.Organizations.OrganizationUser", b => + { + b.HasOne("MicCheck.Api.Organizations.Organization", null) + .WithMany("Members") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MicCheck.Api.Users.User", null) + .WithMany("Organizations") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MicCheck.Api.Projects.Project", b => + { + b.HasOne("MicCheck.Api.Organizations.Organization", null) + .WithMany("Projects") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MicCheck.Api.Segments.Segment", b => + { + b.HasOne("MicCheck.Api.Projects.Project", null) + .WithMany("Segments") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MicCheck.Api.Segments.SegmentCondition", b => + { + b.HasOne("MicCheck.Api.Segments.SegmentRule", null) + .WithMany("Conditions") + .HasForeignKey("RuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MicCheck.Api.Segments.SegmentRule", b => + { + b.HasOne("MicCheck.Api.Segments.SegmentRule", null) + .WithMany("ChildRules") + .HasForeignKey("ParentRuleId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("MicCheck.Api.Segments.Segment", null) + .WithMany("Rules") + .HasForeignKey("SegmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MicCheck.Api.Users.RefreshToken", b => + { + b.HasOne("MicCheck.Api.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("MicCheck.Api.Environments.Environment", b => + { + b.Navigation("FeatureStates"); + }); + + modelBuilder.Entity("MicCheck.Api.Features.Feature", b => + { + b.Navigation("FeatureStates"); + + b.Navigation("Tags"); + }); + + modelBuilder.Entity("MicCheck.Api.Features.FeatureSegment", b => + { + b.Navigation("FeatureState"); + }); + + modelBuilder.Entity("MicCheck.Api.Identities.Identity", b => + { + b.Navigation("FeatureStateOverrides"); + + b.Navigation("Traits"); + }); + + modelBuilder.Entity("MicCheck.Api.Organizations.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Members"); + + b.Navigation("Projects"); + }); + + modelBuilder.Entity("MicCheck.Api.Projects.Project", b => + { + b.Navigation("Environments"); + + b.Navigation("Features"); + + b.Navigation("Segments"); + }); + + modelBuilder.Entity("MicCheck.Api.Segments.Segment", b => + { + b.Navigation("Rules"); + }); + + modelBuilder.Entity("MicCheck.Api.Segments.SegmentRule", b => + { + b.Navigation("ChildRules"); + + b.Navigation("Conditions"); + }); + + modelBuilder.Entity("MicCheck.Api.Users.User", b => + { + b.Navigation("Organizations"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/api/MicCheck.Api/Migrations/20260410175950_AddWebhookDeliveryAttemptNumber.cs b/src/api/MicCheck.Api/Migrations/20260410175950_AddWebhookDeliveryAttemptNumber.cs new file mode 100644 index 0000000..899ddc4 --- /dev/null +++ b/src/api/MicCheck.Api/Migrations/20260410175950_AddWebhookDeliveryAttemptNumber.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MicCheck.Api.Migrations +{ + /// + public partial class AddWebhookDeliveryAttemptNumber : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AttemptNumber", + table: "WebhookDeliveryLogs", + type: "integer", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AttemptNumber", + table: "WebhookDeliveryLogs"); + } + } +} diff --git a/src/api/MicCheck.Api/Migrations/MicCheckDbContextModelSnapshot.cs b/src/api/MicCheck.Api/Migrations/MicCheckDbContextModelSnapshot.cs new file mode 100644 index 0000000..84d3817 --- /dev/null +++ b/src/api/MicCheck.Api/Migrations/MicCheckDbContextModelSnapshot.cs @@ -0,0 +1,895 @@ +// +using System; +using MicCheck.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MicCheck.Api.Migrations +{ + [DbContext(typeof(MicCheckDbContext))] + partial class MicCheckDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MicCheck.Api.ApiKeys.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationId") + .HasColumnType("integer"); + + b.Property("Prefix") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.HasIndex("OrganizationId"); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("MicCheck.Api.Audit.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ActorUserId") + .HasColumnType("integer"); + + b.Property("Changes") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnvironmentId") + .HasColumnType("integer"); + + b.Property("OrganizationId") + .HasColumnType("integer"); + + b.Property("ProjectId") + .HasColumnType("integer"); + + b.Property("ResourceId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ResourceType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "CreatedAt"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("MicCheck.Api.Authorization.UserProjectPermission", b => + { + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("ProjectId") + .HasColumnType("integer"); + + b.Property("IsAdmin") + .HasColumnType("boolean"); + + b.Property("Permissions") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("UserId", "ProjectId"); + + b.ToTable("UserProjectPermissions"); + }); + + modelBuilder.Entity("MicCheck.Api.Environments.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ProjectId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ApiKey") + .IsUnique(); + + b.HasIndex("ProjectId"); + + b.ToTable("Environments"); + }); + + modelBuilder.Entity("MicCheck.Api.Features.Feature", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultEnabled") + .HasColumnType("boolean"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("InitialValue") + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("ProjectId") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId", "Name") + .IsUnique(); + + b.ToTable("Features"); + }); + + modelBuilder.Entity("MicCheck.Api.Features.FeatureSegment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("EnvironmentId") + .HasColumnType("integer"); + + b.Property("FeatureId") + .HasColumnType("integer"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("SegmentId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("FeatureSegments"); + }); + + modelBuilder.Entity("MicCheck.Api.Features.FeatureState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("EnvironmentId") + .HasColumnType("integer"); + + b.Property("FeatureId") + .HasColumnType("integer"); + + b.Property("FeatureSegmentId") + .HasColumnType("integer"); + + b.Property("IdentityId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.HasIndex("FeatureSegmentId") + .IsUnique(); + + b.HasIndex("IdentityId"); + + b.HasIndex("FeatureId", "EnvironmentId", "IdentityId") + .IsUnique(); + + b.ToTable("FeatureStates"); + }); + + modelBuilder.Entity("MicCheck.Api.Features.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Color") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ProjectId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("MicCheck.Api.Identities.Identity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnvironmentId") + .HasColumnType("integer"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId", "Identifier") + .IsUnique(); + + b.ToTable("Identities"); + }); + + modelBuilder.Entity("MicCheck.Api.Identities.IdentityTrait", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("IdentityId") + .HasColumnType("integer"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("ValueType") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("IdentityId", "Key") + .IsUnique(); + + b.ToTable("IdentityTraits"); + }); + + modelBuilder.Entity("MicCheck.Api.Organizations.Organization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.ToTable("Organizations"); + }); + + modelBuilder.Entity("MicCheck.Api.Organizations.OrganizationUser", b => + { + b.Property("OrganizationId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("OrganizationId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("OrganizationUsers"); + }); + + modelBuilder.Entity("MicCheck.Api.Projects.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HideDisabledFlags") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Projects"); + }); + + modelBuilder.Entity("MicCheck.Api.Segments.Segment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ProjectId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("Segments"); + }); + + modelBuilder.Entity("MicCheck.Api.Segments.SegmentCondition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Operator") + .HasColumnType("integer"); + + b.Property("Property") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RuleId") + .HasColumnType("integer"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.HasKey("Id"); + + b.HasIndex("RuleId"); + + b.ToTable("SegmentConditions"); + }); + + modelBuilder.Entity("MicCheck.Api.Segments.SegmentRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ParentRuleId") + .HasColumnType("integer"); + + b.Property("SegmentId") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ParentRuleId"); + + b.HasIndex("SegmentId"); + + b.ToTable("SegmentRules"); + }); + + modelBuilder.Entity("MicCheck.Api.Users.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("MicCheck.Api.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsTwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("MicCheck.Api.Webhooks.Webhook", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("EnvironmentId") + .HasColumnType("integer"); + + b.Property("OrganizationId") + .HasColumnType("integer"); + + b.Property("Scope") + .HasColumnType("integer"); + + b.Property("Secret") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Id"); + + b.HasIndex("EnvironmentId"); + + b.ToTable("Webhooks"); + }); + + modelBuilder.Entity("MicCheck.Api.Webhooks.WebhookDeliveryLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AttemptNumber") + .HasColumnType("integer"); + + b.Property("AttemptedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("ErrorMessage") + .HasColumnType("text"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("ResponseBody") + .HasColumnType("text"); + + b.Property("ResponseStatusCode") + .HasColumnType("integer"); + + b.Property("Success") + .HasColumnType("boolean"); + + b.Property("WebhookId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("WebhookId"); + + b.ToTable("WebhookDeliveryLogs"); + }); + + modelBuilder.Entity("MicCheck.Api.ApiKeys.ApiKey", b => + { + b.HasOne("MicCheck.Api.Organizations.Organization", null) + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MicCheck.Api.Environments.Environment", b => + { + b.HasOne("MicCheck.Api.Projects.Project", null) + .WithMany("Environments") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MicCheck.Api.Features.Feature", b => + { + b.HasOne("MicCheck.Api.Projects.Project", null) + .WithMany("Features") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MicCheck.Api.Features.FeatureState", b => + { + b.HasOne("MicCheck.Api.Environments.Environment", null) + .WithMany("FeatureStates") + .HasForeignKey("EnvironmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MicCheck.Api.Features.Feature", null) + .WithMany("FeatureStates") + .HasForeignKey("FeatureId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MicCheck.Api.Features.FeatureSegment", null) + .WithOne("FeatureState") + .HasForeignKey("MicCheck.Api.Features.FeatureState", "FeatureSegmentId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("MicCheck.Api.Identities.Identity", null) + .WithMany("FeatureStateOverrides") + .HasForeignKey("IdentityId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("MicCheck.Api.Features.Tag", b => + { + b.HasOne("MicCheck.Api.Features.Feature", null) + .WithMany("Tags") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + }); + + modelBuilder.Entity("MicCheck.Api.Identities.IdentityTrait", b => + { + b.HasOne("MicCheck.Api.Identities.Identity", null) + .WithMany("Traits") + .HasForeignKey("IdentityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MicCheck.Api.Organizations.OrganizationUser", b => + { + b.HasOne("MicCheck.Api.Organizations.Organization", null) + .WithMany("Members") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MicCheck.Api.Users.User", null) + .WithMany("Organizations") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MicCheck.Api.Projects.Project", b => + { + b.HasOne("MicCheck.Api.Organizations.Organization", null) + .WithMany("Projects") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MicCheck.Api.Segments.Segment", b => + { + b.HasOne("MicCheck.Api.Projects.Project", null) + .WithMany("Segments") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MicCheck.Api.Segments.SegmentCondition", b => + { + b.HasOne("MicCheck.Api.Segments.SegmentRule", null) + .WithMany("Conditions") + .HasForeignKey("RuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MicCheck.Api.Segments.SegmentRule", b => + { + b.HasOne("MicCheck.Api.Segments.SegmentRule", null) + .WithMany("ChildRules") + .HasForeignKey("ParentRuleId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("MicCheck.Api.Segments.Segment", null) + .WithMany("Rules") + .HasForeignKey("SegmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MicCheck.Api.Users.RefreshToken", b => + { + b.HasOne("MicCheck.Api.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("MicCheck.Api.Environments.Environment", b => + { + b.Navigation("FeatureStates"); + }); + + modelBuilder.Entity("MicCheck.Api.Features.Feature", b => + { + b.Navigation("FeatureStates"); + + b.Navigation("Tags"); + }); + + modelBuilder.Entity("MicCheck.Api.Features.FeatureSegment", b => + { + b.Navigation("FeatureState"); + }); + + modelBuilder.Entity("MicCheck.Api.Identities.Identity", b => + { + b.Navigation("FeatureStateOverrides"); + + b.Navigation("Traits"); + }); + + modelBuilder.Entity("MicCheck.Api.Organizations.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Members"); + + b.Navigation("Projects"); + }); + + modelBuilder.Entity("MicCheck.Api.Projects.Project", b => + { + b.Navigation("Environments"); + + b.Navigation("Features"); + + b.Navigation("Segments"); + }); + + modelBuilder.Entity("MicCheck.Api.Segments.Segment", b => + { + b.Navigation("Rules"); + }); + + modelBuilder.Entity("MicCheck.Api.Segments.SegmentRule", b => + { + b.Navigation("ChildRules"); + + b.Navigation("Conditions"); + }); + + modelBuilder.Entity("MicCheck.Api.Users.User", b => + { + b.Navigation("Organizations"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/api/MicCheck.Api/Organizations/OrganizationsController.cs b/src/api/MicCheck.Api/Organizations/OrganizationsController.cs index 401b8e1..6d0abb6 100644 --- a/src/api/MicCheck.Api/Organizations/OrganizationsController.cs +++ b/src/api/MicCheck.Api/Organizations/OrganizationsController.cs @@ -1,5 +1,6 @@ using MicCheck.Api.Authorization; using MicCheck.Api.Common; +using MicCheck.Api.Webhooks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; @@ -10,7 +11,7 @@ namespace MicCheck.Api.Organizations; [Route("api/v1/organisations")] [Authorize(Policy = AuthorizationPolicies.AdminApiAccess)] [EnableRateLimiting("AdminApi")] -public class OrganizationsController(OrganizationService organizationService) : ControllerBase +public class OrganizationsController(OrganizationService organizationService, WebhookService webhookService) : ControllerBase { [HttpGet] public async Task>> List( @@ -98,6 +99,54 @@ public class OrganizationsController(OrganizationService organizationService) : return NoContent(); } + [HttpGet("{id}/webhooks")] + public async Task>> ListWebhooks(int id, CancellationToken ct) + { + var org = await organizationService.FindByIdAsync(id, ct); + if (org is null) return NotFound(); + + var webhooks = await webhookService.ListByOrganizationAsync(id, ct); + return Ok(webhooks.Select(WebhookResponse.From).ToList()); + } + + [HttpPost("{id}/webhooks")] + public async Task> CreateWebhook( + int id, CreateWebhookRequest request, CancellationToken ct) + { + var org = await organizationService.FindByIdAsync(id, ct); + if (org is null) return NotFound(); + + var webhook = await webhookService.CreateForOrganizationAsync(id, request.Url, request.Secret, request.Enabled, ct); + return CreatedAtAction(nameof(ListWebhooks), new { id }, WebhookResponse.From(webhook)); + } + + [HttpPut("{id}/webhooks/{webhookId}")] + public async Task> UpdateWebhook( + int id, int webhookId, CreateWebhookRequest request, CancellationToken ct) + { + var org = await organizationService.FindByIdAsync(id, ct); + if (org is null) return NotFound(); + + var webhook = await webhookService.FindByIdAsync(webhookId, ct); + if (webhook is null || webhook.OrganizationId != id) return NotFound(); + + var updated = await webhookService.UpdateAsync(webhookId, request.Url, request.Secret, request.Enabled, ct); + return Ok(WebhookResponse.From(updated)); + } + + [HttpDelete("{id}/webhooks/{webhookId}")] + public async Task DeleteWebhook(int id, int webhookId, CancellationToken ct) + { + var org = await organizationService.FindByIdAsync(id, ct); + if (org is null) return NotFound(); + + var webhook = await webhookService.FindByIdAsync(webhookId, ct); + if (webhook is null || webhook.OrganizationId != id) return NotFound(); + + await webhookService.DeleteAsync(webhookId, ct); + return NoContent(); + } + private int? GetCurrentUserId() { var claim = User.FindFirst("UserId")?.Value; diff --git a/src/api/MicCheck.Api/Program.cs b/src/api/MicCheck.Api/Program.cs index c21ef6f..2134349 100644 --- a/src/api/MicCheck.Api/Program.cs +++ b/src/api/MicCheck.Api/Program.cs @@ -4,7 +4,6 @@ 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; using MicCheck.Api.Data; @@ -130,7 +129,13 @@ try builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddSingleton(); + builder.Services.AddHostedService(); + builder.Services.AddHostedService(); + builder.Services.AddHttpClient("Webhooks", client => + client.DefaultRequestHeaders.Add("User-Agent", "MicCheck-Webhook/1.0")); var connectionString = System.Environment.GetEnvironmentVariable("DATABASE_URL") is { } databaseUrl ? DatabaseUrlParser.ToNpgsqlConnectionString(databaseUrl) @@ -145,6 +150,7 @@ try { app.MapOpenApi(); app.MapScalarApiReference(); + app.MapGet("/", () => Results.Redirect("/scalar/v1")).ExcludeFromDescription(); using var scope = app.Services.CreateScope(); var seeder = scope.ServiceProvider.GetRequiredService(); diff --git a/src/api/MicCheck.Api/Properties/launchSettings.json b/src/api/MicCheck.Api/Properties/launchSettings.json new file mode 100644 index 0000000..524de7e --- /dev/null +++ b/src/api/MicCheck.Api/Properties/launchSettings.json @@ -0,0 +1,22 @@ +{ + "profiles": { + "http": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "", + "applicationUrl": "http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "", + "applicationUrl": "https://localhost:5001;http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/api/MicCheck.Api/Webhooks/WebhookBackgroundService.cs b/src/api/MicCheck.Api/Webhooks/WebhookBackgroundService.cs new file mode 100644 index 0000000..01eb755 --- /dev/null +++ b/src/api/MicCheck.Api/Webhooks/WebhookBackgroundService.cs @@ -0,0 +1,27 @@ +using MicCheck.Api.Data; +using Microsoft.Extensions.DependencyInjection; + +namespace MicCheck.Api.Webhooks; + +public class WebhookBackgroundService( + WebhookQueue queue, + IServiceScopeFactory scopeFactory, + ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await foreach (var webhookEvent in queue.ReadAllAsync(stoppingToken)) + { + try + { + await using var scope = scopeFactory.CreateAsyncScope(); + var dispatcher = scope.ServiceProvider.GetRequiredService(); + await dispatcher.DispatchAsync(webhookEvent, attemptNumber: 1, stoppingToken); + } + catch (Exception ex) + { + logger.LogError(ex, "Unhandled error dispatching webhook event {EventType}", webhookEvent.EventType); + } + } + } +} diff --git a/src/api/MicCheck.Api/Webhooks/WebhookDeliveryLog.cs b/src/api/MicCheck.Api/Webhooks/WebhookDeliveryLog.cs index 4522908..ccc6d3a 100644 --- a/src/api/MicCheck.Api/Webhooks/WebhookDeliveryLog.cs +++ b/src/api/MicCheck.Api/Webhooks/WebhookDeliveryLog.cs @@ -10,6 +10,7 @@ public class WebhookDeliveryLog public string? ResponseBody { get; set; } public bool Success { get; set; } public string? ErrorMessage { get; set; } + public int AttemptNumber { get; init; } public DateTimeOffset AttemptedAt { get; init; } public TimeSpan Duration { get; set; } } diff --git a/src/api/MicCheck.Api/Webhooks/WebhookDeliveryLogResponse.cs b/src/api/MicCheck.Api/Webhooks/WebhookDeliveryLogResponse.cs new file mode 100644 index 0000000..aa8d181 --- /dev/null +++ b/src/api/MicCheck.Api/Webhooks/WebhookDeliveryLogResponse.cs @@ -0,0 +1,27 @@ +namespace MicCheck.Api.Webhooks; + +public record WebhookDeliveryLogResponse( + int Id, + int WebhookId, + string EventType, + bool Success, + int? ResponseStatusCode, + string? ResponseBody, + string? ErrorMessage, + int AttemptNumber, + DateTimeOffset AttemptedAt, + TimeSpan Duration +) +{ + public static WebhookDeliveryLogResponse From(WebhookDeliveryLog log) => new( + log.Id, + log.WebhookId, + log.EventType, + log.Success, + log.ResponseStatusCode, + log.ResponseBody, + log.ErrorMessage, + log.AttemptNumber, + log.AttemptedAt, + log.Duration); +} diff --git a/src/api/MicCheck.Api/Webhooks/WebhookDispatcher.cs b/src/api/MicCheck.Api/Webhooks/WebhookDispatcher.cs new file mode 100644 index 0000000..d1c3e8d --- /dev/null +++ b/src/api/MicCheck.Api/Webhooks/WebhookDispatcher.cs @@ -0,0 +1,110 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using MicCheck.Api.Data; +using Microsoft.EntityFrameworkCore; + +namespace MicCheck.Api.Webhooks; + +public class WebhookDispatcher(MicCheckDbContext db, IHttpClientFactory httpClientFactory, ILogger logger) +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + public async Task DispatchAsync(WebhookEvent webhookEvent, int attemptNumber = 1, CancellationToken ct = default) + { + var webhooks = await LoadWebhooksForEventAsync(webhookEvent, ct); + if (webhooks.Count == 0) return; + + var payload = new WebhookPayload(webhookEvent.EventType, webhookEvent.Data); + var payloadJson = JsonSerializer.Serialize(payload, JsonOptions); + + var tasks = webhooks.Select(w => DeliverAsync(w, payloadJson, attemptNumber, ct)); + await Task.WhenAll(tasks); + } + + private async Task> LoadWebhooksForEventAsync(WebhookEvent webhookEvent, CancellationToken ct) + { + var query = db.Webhooks.Where(w => w.Enabled); + + if (webhookEvent.EnvironmentId.HasValue) + { + query = query.Where(w => + (w.Scope == WebhookScope.Environment && w.EnvironmentId == webhookEvent.EnvironmentId) || + (w.Scope == WebhookScope.Organization && w.OrganizationId == webhookEvent.OrganizationId)); + } + else + { + query = query.Where(w => + w.Scope == WebhookScope.Organization && w.OrganizationId == webhookEvent.OrganizationId); + } + + return await query.ToListAsync(ct); + } + + private async Task DeliverAsync(Webhook webhook, string payloadJson, int attemptNumber, CancellationToken ct) + { + var started = DateTimeOffset.UtcNow; + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + int? statusCode = null; + string? responseBody = null; + string? errorMessage = null; + var success = false; + + try + { + var client = httpClientFactory.CreateClient("Webhooks"); + using var request = new HttpRequestMessage(HttpMethod.Post, webhook.Url); + request.Content = new StringContent(payloadJson, Encoding.UTF8, "application/json"); + + if (!string.IsNullOrEmpty(webhook.Secret)) + request.Headers.Add("X-Flagsmith-Signature", ComputeSignature(webhook.Secret, payloadJson)); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(TimeSpan.FromSeconds(10)); + + using var response = await client.SendAsync(request, cts.Token); + statusCode = (int)response.StatusCode; + responseBody = await response.Content.ReadAsStringAsync(ct); + success = response.IsSuccessStatusCode; + } + catch (Exception ex) + { + errorMessage = ex.Message; + logger.LogWarning(ex, "Webhook delivery failed for webhook {WebhookId} attempt {Attempt}", webhook.Id, attemptNumber); + } + finally + { + stopwatch.Stop(); + } + + db.WebhookDeliveryLogs.Add(new WebhookDeliveryLog + { + WebhookId = webhook.Id, + EventType = payloadJson.Length > 100 + ? payloadJson[..100] + : payloadJson, + PayloadJson = payloadJson, + ResponseStatusCode = statusCode, + ResponseBody = responseBody, + Success = success, + ErrorMessage = errorMessage, + AttemptNumber = attemptNumber, + AttemptedAt = started, + Duration = stopwatch.Elapsed + }); + + await db.SaveChangesAsync(ct); + } + + public static string ComputeSignature(string secret, string payload) + { + var keyBytes = Encoding.UTF8.GetBytes(secret); + var payloadBytes = Encoding.UTF8.GetBytes(payload); + var hashBytes = HMACSHA256.HashData(keyBytes, payloadBytes); + return "sha256=" + Convert.ToHexString(hashBytes).ToLowerInvariant(); + } +} diff --git a/src/api/MicCheck.Api/Webhooks/WebhookEvent.cs b/src/api/MicCheck.Api/Webhooks/WebhookEvent.cs new file mode 100644 index 0000000..0f603d0 --- /dev/null +++ b/src/api/MicCheck.Api/Webhooks/WebhookEvent.cs @@ -0,0 +1,16 @@ +namespace MicCheck.Api.Webhooks; + +public class WebhookEvent +{ + public required string EventType { get; init; } + public int? EnvironmentId { get; init; } + public int OrganizationId { get; init; } + public required object Data { get; init; } +} + +public static class WebhookEventTypes +{ + public const string FlagUpdated = "FLAG_UPDATED"; + public const string FlagDeleted = "FLAG_DELETED"; + public const string AuditLogCreated = "AUDIT_LOG_CREATED"; +} diff --git a/src/api/MicCheck.Api/Webhooks/WebhookPayload.cs b/src/api/MicCheck.Api/Webhooks/WebhookPayload.cs new file mode 100644 index 0000000..8f6b61e --- /dev/null +++ b/src/api/MicCheck.Api/Webhooks/WebhookPayload.cs @@ -0,0 +1,39 @@ +using System.Text.Json.Serialization; + +namespace MicCheck.Api.Webhooks; + +public record WebhookPayload( + [property: JsonPropertyName("event_type")] string EventType, + [property: JsonPropertyName("data")] object Data +); + +public record FlagUpdatedData( + [property: JsonPropertyName("changed_by")] string? ChangedBy, + [property: JsonPropertyName("timestamp")] DateTimeOffset Timestamp, + [property: JsonPropertyName("new_state")] FlagStateSnapshot? NewState, + [property: JsonPropertyName("previous_state")] FlagStateSnapshot? PreviousState +); + +public record FlagDeletedData( + [property: JsonPropertyName("changed_by")] string? ChangedBy, + [property: JsonPropertyName("timestamp")] DateTimeOffset Timestamp, + [property: JsonPropertyName("feature")] FeatureSummary Feature +); + +public record FlagStateSnapshot( + [property: JsonPropertyName("id")] int Id, + [property: JsonPropertyName("enabled")] bool Enabled, + [property: JsonPropertyName("feature_state_value")] string? FeatureStateValue, + [property: JsonPropertyName("feature")] FeatureSummary Feature, + [property: JsonPropertyName("environment")] EnvironmentSummary Environment +); + +public record FeatureSummary( + [property: JsonPropertyName("id")] int Id, + [property: JsonPropertyName("name")] string Name +); + +public record EnvironmentSummary( + [property: JsonPropertyName("id")] int Id, + [property: JsonPropertyName("name")] string Name +); diff --git a/src/api/MicCheck.Api/Webhooks/WebhookQueue.cs b/src/api/MicCheck.Api/Webhooks/WebhookQueue.cs new file mode 100644 index 0000000..9e141a0 --- /dev/null +++ b/src/api/MicCheck.Api/Webhooks/WebhookQueue.cs @@ -0,0 +1,15 @@ +using System.Threading.Channels; + +namespace MicCheck.Api.Webhooks; + +public class WebhookQueue +{ + private readonly Channel _channel = Channel.CreateUnbounded( + new UnboundedChannelOptions { SingleReader = true }); + + public ValueTask EnqueueAsync(WebhookEvent webhookEvent) => + _channel.Writer.WriteAsync(webhookEvent); + + public IAsyncEnumerable ReadAllAsync(CancellationToken ct) => + _channel.Reader.ReadAllAsync(ct); +} diff --git a/src/api/MicCheck.Api/Webhooks/WebhookRetryBackgroundService.cs b/src/api/MicCheck.Api/Webhooks/WebhookRetryBackgroundService.cs new file mode 100644 index 0000000..6a44ed9 --- /dev/null +++ b/src/api/MicCheck.Api/Webhooks/WebhookRetryBackgroundService.cs @@ -0,0 +1,80 @@ +using MicCheck.Api.Data; +using Microsoft.EntityFrameworkCore; + +namespace MicCheck.Api.Webhooks; + +public class WebhookRetryBackgroundService( + IServiceScopeFactory scopeFactory, + ILogger logger) : BackgroundService +{ + private static readonly TimeSpan[] RetryDelays = [TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(30)]; + private const int MaxAttempts = 3; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + await ProcessPendingRetriesAsync(stoppingToken); + } + catch (Exception ex) + { + logger.LogError(ex, "Error during webhook retry processing"); + } + + await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); + } + } + + private async Task ProcessPendingRetriesAsync(CancellationToken ct) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var dispatcher = scope.ServiceProvider.GetRequiredService(); + + var now = DateTimeOffset.UtcNow; + + for (var attemptNumber = 1; attemptNumber < MaxAttempts; attemptNumber++) + { + var delay = RetryDelays[attemptNumber - 1]; + var retryAfter = now - delay; + + var failedDeliveries = await db.WebhookDeliveryLogs + .Where(d => !d.Success && d.AttemptNumber == attemptNumber && d.AttemptedAt <= retryAfter) + .ToListAsync(ct); + + foreach (var delivery in failedDeliveries) + { + var alreadyRetried = await db.WebhookDeliveryLogs + .AnyAsync(d => d.WebhookId == delivery.WebhookId + && d.AttemptNumber == attemptNumber + 1 + && d.AttemptedAt > delivery.AttemptedAt, ct); + + if (alreadyRetried) continue; + + var webhook = await db.Webhooks.FirstOrDefaultAsync(w => w.Id == delivery.WebhookId, ct); + if (webhook is null || !webhook.Enabled) continue; + + try + { + var orgId = webhook.OrganizationId ?? 0; + var webhookEvent = new WebhookEvent + { + EventType = delivery.EventType, + EnvironmentId = webhook.EnvironmentId, + OrganizationId = orgId, + Data = delivery.PayloadJson + }; + + await dispatcher.DispatchAsync(webhookEvent, attemptNumber + 1, ct); + logger.LogInformation("Retried webhook {WebhookId} attempt {Attempt}", delivery.WebhookId, attemptNumber + 1); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Retry attempt {Attempt} failed for webhook {WebhookId}", attemptNumber + 1, delivery.WebhookId); + } + } + } + } +} diff --git a/src/api/MicCheck.Api/Webhooks/WebhookService.cs b/src/api/MicCheck.Api/Webhooks/WebhookService.cs index edfb91d..5b77709 100644 --- a/src/api/MicCheck.Api/Webhooks/WebhookService.cs +++ b/src/api/MicCheck.Api/Webhooks/WebhookService.cs @@ -53,4 +53,35 @@ public class WebhookService(MicCheckDbContext db) db.Webhooks.Remove(webhook); await db.SaveChangesAsync(ct); } + + public async Task> ListByOrganizationAsync(int organizationId, CancellationToken ct = default) + { + return await db.Webhooks + .Where(w => w.OrganizationId == organizationId && w.Scope == WebhookScope.Organization) + .ToListAsync(ct); + } + + public async Task CreateForOrganizationAsync(int organizationId, string url, string? secret, bool enabled, CancellationToken ct = default) + { + var webhook = new Webhook + { + Url = url, + Secret = secret, + Scope = WebhookScope.Organization, + OrganizationId = organizationId, + Enabled = enabled, + CreatedAt = DateTimeOffset.UtcNow + }; + db.Webhooks.Add(webhook); + await db.SaveChangesAsync(ct); + return webhook; + } + + public async Task> ListDeliveriesAsync(int webhookId, CancellationToken ct = default) + { + return await db.WebhookDeliveryLogs + .Where(d => d.WebhookId == webhookId) + .OrderByDescending(d => d.AttemptedAt) + .ToListAsync(ct); + } } diff --git a/tests/api/MicCheck.Api.Tests.Unit/Audit/AuditServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Audit/AuditServiceTests.cs new file mode 100644 index 0000000..5047d4a --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Audit/AuditServiceTests.cs @@ -0,0 +1,116 @@ +using System.Text.Json; +using MicCheck.Api.Audit; +using MicCheck.Api.Data; +using MicCheck.Api.Webhooks; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Moq; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Audit; + +[TestFixture] +public class AuditServiceTests +{ + private MicCheckDbContext _db = null!; + private AuditService _service = null!; + private WebhookQueue _queue = null!; + + [SetUp] + public void SetUp() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + _db = new MicCheckDbContext(options); + _queue = new WebhookQueue(); + + var httpContextAccessor = new Mock(); + httpContextAccessor.Setup(x => x.HttpContext).Returns((Microsoft.AspNetCore.Http.HttpContext?)null); + + _service = new AuditService(_db, httpContextAccessor.Object, _queue); + } + + [TearDown] + public void TearDown() => _db.Dispose(); + + [Test] + public async Task WhenRecordingWithNoBefore_ThenChangesIsNull() + { + await _service.RecordAsync("Feature", "1", "created", organizationId: 1); + + var log = await _db.AuditLogs.FirstAsync(); + Assert.That(log.Changes, Is.Null); + } + + [Test] + public async Task WhenRecordingWithAfterOnly_ThenChangesContainsAfterJson() + { + var after = new { Name = "dark_mode", Enabled = true }; + await _service.RecordAsync("Feature", "1", "created", organizationId: 1, after: after); + + var log = await _db.AuditLogs.FirstAsync(); + Assert.That(log.Changes, Is.Not.Null); + + var changes = JsonDocument.Parse(log.Changes!).RootElement; + Assert.That(changes.GetProperty("after").GetProperty("name").GetString(), Is.EqualTo("dark_mode")); + Assert.That(changes.TryGetProperty("before", out var beforeProp), Is.True); + Assert.That(beforeProp.ValueKind, Is.EqualTo(JsonValueKind.Null)); + } + + [Test] + public async Task WhenRecordingWithBeforeAndAfter_ThenChangesCapturesBothStates() + { + var before = new { Enabled = false, Value = "old" }; + var after = new { Enabled = true, Value = "new" }; + + await _service.RecordAsync("FeatureState", "42", "updated", organizationId: 1, + before: before, after: after); + + var log = await _db.AuditLogs.FirstAsync(); + var changes = JsonDocument.Parse(log.Changes!).RootElement; + + Assert.That(changes.GetProperty("before").GetProperty("enabled").GetBoolean(), Is.False); + Assert.That(changes.GetProperty("after").GetProperty("enabled").GetBoolean(), Is.True); + Assert.That(changes.GetProperty("before").GetProperty("value").GetString(), Is.EqualTo("old")); + Assert.That(changes.GetProperty("after").GetProperty("value").GetString(), Is.EqualTo("new")); + } + + [Test] + public async Task WhenRecording_ThenMetadataIsPersistedCorrectly() + { + await _service.RecordAsync( + "Segment", "5", "deleted", + organizationId: 10, projectId: 20, environmentId: 30); + + var log = await _db.AuditLogs.FirstAsync(); + Assert.That(log.ResourceType, Is.EqualTo("Segment")); + Assert.That(log.ResourceId, Is.EqualTo("5")); + Assert.That(log.Action, Is.EqualTo("deleted")); + Assert.That(log.OrganizationId, Is.EqualTo(10)); + Assert.That(log.ProjectId, Is.EqualTo(20)); + Assert.That(log.EnvironmentId, Is.EqualTo(30)); + Assert.That(log.ActorUserId, Is.Null); + } + + [Test] + public async Task WhenRecording_ThenAuditLogCreatedEventIsEnqueued() + { + await _service.RecordAsync("Feature", "1", "created", organizationId: 1); + + var events = new List(); + var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); + try + { + await foreach (var e in _queue.ReadAllAsync(cts.Token)) + { + events.Add(e); + break; + } + } + catch (OperationCanceledException) { } + + Assert.That(events, Has.Count.EqualTo(1)); + Assert.That(events[0].EventType, Is.EqualTo(WebhookEventTypes.AuditLogCreated)); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Auth/AuthEndpointsTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Authorization/AuthEndpointsTests.cs similarity index 98% rename from tests/api/MicCheck.Api.Tests.Unit/Auth/AuthEndpointsTests.cs rename to tests/api/MicCheck.Api.Tests.Unit/Authorization/AuthEndpointsTests.cs index e5b39e8..092c610 100644 --- a/tests/api/MicCheck.Api.Tests.Unit/Auth/AuthEndpointsTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Authorization/AuthEndpointsTests.cs @@ -1,9 +1,9 @@ using System.Net; using System.Net.Http.Json; -using MicCheck.Api.Auth; +using MicCheck.Api.Authorization; using NUnit.Framework; -namespace MicCheck.Api.Tests.Unit.Auth; +namespace MicCheck.Api.Tests.Unit.Authorization; [TestFixture] public class AuthEndpointsTests diff --git a/tests/api/MicCheck.Api.Tests.Unit/Auth/TokenServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Authorization/TokenServiceTests.cs similarity index 97% rename from tests/api/MicCheck.Api.Tests.Unit/Auth/TokenServiceTests.cs rename to tests/api/MicCheck.Api.Tests.Unit/Authorization/TokenServiceTests.cs index 94b852f..8235942 100644 --- a/tests/api/MicCheck.Api.Tests.Unit/Auth/TokenServiceTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Authorization/TokenServiceTests.cs @@ -1,11 +1,11 @@ using System.IdentityModel.Tokens.Jwt; -using MicCheck.Api.Auth; +using MicCheck.Api.Authorization; using MicCheck.Api.Organizations; using MicCheck.Api.Users; using Microsoft.Extensions.Configuration; using NUnit.Framework; -namespace MicCheck.Api.Tests.Unit.Auth; +namespace MicCheck.Api.Tests.Unit.Authorization; [TestFixture] public class TokenServiceTests diff --git a/tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentServiceTests.cs index acba748..114c846 100644 --- a/tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentServiceTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentServiceTests.cs @@ -25,7 +25,8 @@ public class EnvironmentServiceTests .Options; _db = new MicCheckDbContext(options); - var auditService = new Mock(_db, null!); + var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue(); + var auditService = new Mock(_db, null!, webhookQueue); auditService.Setup(a => a.LogAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), diff --git a/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureServiceTests.cs index 8fc2d80..da84b9a 100644 --- a/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureServiceTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureServiceTests.cs @@ -26,14 +26,15 @@ public class FeatureServiceTests .Options; _db = new MicCheckDbContext(options); - var auditService = new Mock(_db, null!); - auditService.Setup(a => a.LogAsync( + var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue(); + var auditService = new Mock(_db, null!, webhookQueue); + auditService.Setup(a => a.RecordAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); - _service = new FeatureService(_db, auditService.Object); + _service = new FeatureService(_db, auditService.Object, webhookQueue); SeedBaseData(); } diff --git a/tests/api/MicCheck.Api.Tests.Unit/Segments/SegmentServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Segments/SegmentServiceTests.cs index b804cc8..fbc92e4 100644 --- a/tests/api/MicCheck.Api.Tests.Unit/Segments/SegmentServiceTests.cs +++ b/tests/api/MicCheck.Api.Tests.Unit/Segments/SegmentServiceTests.cs @@ -24,7 +24,8 @@ public class SegmentServiceTests .Options; _db = new MicCheckDbContext(options); - var auditService = new Mock(_db, null!); + var webhookQueue = new MicCheck.Api.Webhooks.WebhookQueue(); + var auditService = new Mock(_db, null!, webhookQueue); auditService.Setup(a => a.LogAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), diff --git a/tests/api/MicCheck.Api.Tests.Unit/Webhooks/WebhookDispatcherTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Webhooks/WebhookDispatcherTests.cs new file mode 100644 index 0000000..09b3ca8 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Webhooks/WebhookDispatcherTests.cs @@ -0,0 +1,227 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text; +using MicCheck.Api.Data; +using MicCheck.Api.Webhooks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Moq.Protected; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Webhooks; + +[TestFixture] +public class WebhookDispatcherTests +{ + [Test] + public void WhenComputingSignature_ThenItMatchesHmacSha256() + { + const string secret = "my-secret"; + const string payload = """{"event_type":"FLAG_UPDATED","data":{}}"""; + + var result = WebhookDispatcher.ComputeSignature(secret, payload); + + var keyBytes = Encoding.UTF8.GetBytes(secret); + var payloadBytes = Encoding.UTF8.GetBytes(payload); + var expected = "sha256=" + Convert.ToHexString(HMACSHA256.HashData(keyBytes, payloadBytes)).ToLowerInvariant(); + + Assert.That(result, Is.EqualTo(expected)); + } + + [Test] + public void WhenComputingSignatureWithDifferentPayloads_ThenResultsDiffer() + { + const string secret = "my-secret"; + var sig1 = WebhookDispatcher.ComputeSignature(secret, "payload-one"); + var sig2 = WebhookDispatcher.ComputeSignature(secret, "payload-two"); + + Assert.That(sig1, Is.Not.EqualTo(sig2)); + } + + [Test] + public void WhenComputingSignatureWithDifferentSecrets_ThenResultsDiffer() + { + const string payload = "same-payload"; + var sig1 = WebhookDispatcher.ComputeSignature("secret-a", payload); + var sig2 = WebhookDispatcher.ComputeSignature("secret-b", payload); + + Assert.That(sig1, Is.Not.EqualTo(sig2)); + } + + [Test] + public async Task WhenDispatchingToActiveWebhook_ThenPayloadIsPostedWithSignatureHeader() + { + var (db, factory, capturedRequests) = SetUpDispatcher(HttpStatusCode.OK); + + var org = new MicCheck.Api.Organizations.Organization { Name = "Org", CreatedAt = DateTimeOffset.UtcNow }; + db.Organizations.Add(org); + db.SaveChanges(); + + db.Webhooks.Add(new Webhook + { + Url = "https://example.com/hook", + Secret = "test-secret", + Scope = WebhookScope.Organization, + OrganizationId = org.Id, + Enabled = true, + CreatedAt = DateTimeOffset.UtcNow + }); + db.SaveChanges(); + + var dispatcher = new WebhookDispatcher(db, factory, NullLogger.Instance); + var webhookEvent = new WebhookEvent + { + EventType = WebhookEventTypes.FlagUpdated, + OrganizationId = org.Id, + Data = new { test = true } + }; + + await dispatcher.DispatchAsync(webhookEvent); + + Assert.That(capturedRequests, Has.Count.EqualTo(1)); + Assert.That(capturedRequests[0].Headers.Contains("X-Flagsmith-Signature"), Is.True); + } + + [Test] + public async Task WhenDispatchingToWebhookWithoutSecret_ThenNoSignatureHeaderIsAdded() + { + var (db, factory, capturedRequests) = SetUpDispatcher(HttpStatusCode.OK); + + var org = new MicCheck.Api.Organizations.Organization { Name = "Org", CreatedAt = DateTimeOffset.UtcNow }; + db.Organizations.Add(org); + db.SaveChanges(); + + db.Webhooks.Add(new Webhook + { + Url = "https://example.com/hook", + Secret = null, + Scope = WebhookScope.Organization, + OrganizationId = org.Id, + Enabled = true, + CreatedAt = DateTimeOffset.UtcNow + }); + db.SaveChanges(); + + var dispatcher = new WebhookDispatcher(db, factory, NullLogger.Instance); + await dispatcher.DispatchAsync(new WebhookEvent + { + EventType = WebhookEventTypes.FlagUpdated, + OrganizationId = org.Id, + Data = new { } + }); + + Assert.That(capturedRequests[0].Headers.Contains("X-Flagsmith-Signature"), Is.False); + } + + [Test] + public async Task WhenDeliverySucceeds_ThenDeliveryLogIsRecordedAsSuccess() + { + var (db, factory, _) = SetUpDispatcher(HttpStatusCode.OK); + + var org = new MicCheck.Api.Organizations.Organization { Name = "Org", CreatedAt = DateTimeOffset.UtcNow }; + db.Organizations.Add(org); + db.SaveChanges(); + + db.Webhooks.Add(new Webhook + { + Url = "https://example.com/hook", + Scope = WebhookScope.Organization, + OrganizationId = org.Id, + Enabled = true, + CreatedAt = DateTimeOffset.UtcNow + }); + db.SaveChanges(); + + var dispatcher = new WebhookDispatcher(db, factory, NullLogger.Instance); + await dispatcher.DispatchAsync(new WebhookEvent + { + EventType = WebhookEventTypes.FlagUpdated, + OrganizationId = org.Id, + Data = new { } + }); + + var log = db.WebhookDeliveryLogs.First(); + Assert.That(log.Success, Is.True); + Assert.That(log.ResponseStatusCode, Is.EqualTo(200)); + Assert.That(log.AttemptNumber, Is.EqualTo(1)); + } + + [Test] + public async Task WhenDeliveryFails_ThenDeliveryLogIsRecordedAsFailure() + { + var (db, factory, _) = SetUpDispatcher(HttpStatusCode.InternalServerError); + + var org = new MicCheck.Api.Organizations.Organization { Name = "Org", CreatedAt = DateTimeOffset.UtcNow }; + db.Organizations.Add(org); + db.SaveChanges(); + + db.Webhooks.Add(new Webhook + { + Url = "https://example.com/hook", + Scope = WebhookScope.Organization, + OrganizationId = org.Id, + Enabled = true, + CreatedAt = DateTimeOffset.UtcNow + }); + db.SaveChanges(); + + var dispatcher = new WebhookDispatcher(db, factory, NullLogger.Instance); + await dispatcher.DispatchAsync(new WebhookEvent + { + EventType = WebhookEventTypes.FlagUpdated, + OrganizationId = org.Id, + Data = new { } + }); + + var log = db.WebhookDeliveryLogs.First(); + Assert.That(log.Success, Is.False); + Assert.That(log.ResponseStatusCode, Is.EqualTo(500)); + } + + [Test] + public async Task WhenNoWebhooksAreRegistered_ThenNoDeliveryLogsAreCreated() + { + var (db, factory, _) = SetUpDispatcher(HttpStatusCode.OK); + + var dispatcher = new WebhookDispatcher(db, factory, NullLogger.Instance); + await dispatcher.DispatchAsync(new WebhookEvent + { + EventType = WebhookEventTypes.FlagUpdated, + OrganizationId = 99, + Data = new { } + }); + + Assert.That(db.WebhookDeliveryLogs.Count(), Is.EqualTo(0)); + } + + private static (MicCheckDbContext Db, IHttpClientFactory Factory, List CapturedRequests) + SetUpDispatcher(HttpStatusCode responseStatus) + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var db = new MicCheckDbContext(options); + + var capturedRequests = new List(); + var handler = new Mock(); + handler.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync((HttpRequestMessage req, CancellationToken _) => + { + capturedRequests.Add(req); + return new HttpResponseMessage(responseStatus) + { + Content = new StringContent("ok") + }; + }); + + var httpClient = new HttpClient(handler.Object); + var factory = new Mock(); + factory.Setup(f => f.CreateClient(It.IsAny())).Returns(httpClient); + + return (db, factory.Object, capturedRequests); + } +} diff --git a/tests/api/MicCheck.Api.Tests.Unit/Webhooks/WebhookRetryTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Webhooks/WebhookRetryTests.cs new file mode 100644 index 0000000..2592775 --- /dev/null +++ b/tests/api/MicCheck.Api.Tests.Unit/Webhooks/WebhookRetryTests.cs @@ -0,0 +1,121 @@ +using MicCheck.Api.Data; +using MicCheck.Api.Webhooks; +using Microsoft.EntityFrameworkCore; +using NUnit.Framework; + +namespace MicCheck.Api.Tests.Unit.Webhooks; + +[TestFixture] +public class WebhookRetryTests +{ + [Test] + public void WhenRetryDelaysAreConfigured_ThenFirstRetryIsAfter5Minutes() + { + // Retry delay for attempt 1 → 2 is 5 minutes + var delay = TimeSpan.FromMinutes(5); + Assert.That(delay.TotalMinutes, Is.EqualTo(5)); + } + + [Test] + public void WhenRetryDelaysAreConfigured_ThenSecondRetryIsAfter30Minutes() + { + // Retry delay for attempt 2 → 3 is 30 minutes + var delay = TimeSpan.FromMinutes(30); + Assert.That(delay.TotalMinutes, Is.EqualTo(30)); + } + + [Test] + public async Task WhenFailedDeliveryIsOldEnough_ThenItIsEligibleForRetry() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + using var db = new MicCheckDbContext(options); + + db.WebhookDeliveryLogs.Add(new WebhookDeliveryLog + { + WebhookId = 1, + EventType = WebhookEventTypes.FlagUpdated, + PayloadJson = """{"event_type":"FLAG_UPDATED","data":{}}""", + Success = false, + AttemptNumber = 1, + AttemptedAt = DateTimeOffset.UtcNow.AddMinutes(-6), + Duration = TimeSpan.FromMilliseconds(100) + }); + await db.SaveChangesAsync(); + + var retryAfter = DateTimeOffset.UtcNow - TimeSpan.FromMinutes(5); + var eligible = await db.WebhookDeliveryLogs + .Where(d => !d.Success && d.AttemptNumber == 1 && d.AttemptedAt <= retryAfter) + .ToListAsync(); + + Assert.That(eligible, Has.Count.EqualTo(1)); + } + + [Test] + public async Task WhenFailedDeliveryIsTooRecent_ThenItIsNotEligibleForRetry() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + using var db = new MicCheckDbContext(options); + + db.WebhookDeliveryLogs.Add(new WebhookDeliveryLog + { + WebhookId = 1, + EventType = WebhookEventTypes.FlagUpdated, + PayloadJson = """{"event_type":"FLAG_UPDATED","data":{}}""", + Success = false, + AttemptNumber = 1, + AttemptedAt = DateTimeOffset.UtcNow.AddMinutes(-1), + Duration = TimeSpan.FromMilliseconds(100) + }); + await db.SaveChangesAsync(); + + var retryAfter = DateTimeOffset.UtcNow - TimeSpan.FromMinutes(5); + var eligible = await db.WebhookDeliveryLogs + .Where(d => !d.Success && d.AttemptNumber == 1 && d.AttemptedAt <= retryAfter) + .ToListAsync(); + + Assert.That(eligible, Is.Empty); + } + + [Test] + public async Task WhenDeliveryHasAlreadyBeenRetried_ThenItIsNotRetriedAgain() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + using var db = new MicCheckDbContext(options); + + var firstAttemptedAt = DateTimeOffset.UtcNow.AddMinutes(-10); + + db.WebhookDeliveryLogs.Add(new WebhookDeliveryLog + { + WebhookId = 1, + EventType = WebhookEventTypes.FlagUpdated, + PayloadJson = "{}", + Success = false, + AttemptNumber = 1, + AttemptedAt = firstAttemptedAt, + Duration = TimeSpan.Zero + }); + + db.WebhookDeliveryLogs.Add(new WebhookDeliveryLog + { + WebhookId = 1, + EventType = WebhookEventTypes.FlagUpdated, + PayloadJson = "{}", + Success = false, + AttemptNumber = 2, + AttemptedAt = firstAttemptedAt.AddMinutes(5), + Duration = TimeSpan.Zero + }); + await db.SaveChangesAsync(); + + var alreadyRetried = await db.WebhookDeliveryLogs + .AnyAsync(d => d.WebhookId == 1 && d.AttemptNumber == 2 && d.AttemptedAt > firstAttemptedAt); + + Assert.That(alreadyRetried, Is.True); + } +}