diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..a420145
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,154 @@
+
+[*.{cs,vb}]
+#### Naming styles ####
+
+# Naming rules
+
+dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion
+dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface
+dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i
+
+dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion
+dotnet_naming_rule.types_should_be_pascal_case.symbols = types
+dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case
+
+dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion
+dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members
+dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case
+
+dotnet_sort_system_directives_first = true
+
+# Enforce private fields to start with an underscore
+dotnet_naming_rule.private_fields_with_underscore.severity = suggestion
+dotnet_naming_rule.private_fields_with_underscore.symbols = private_fields
+dotnet_naming_rule.private_fields_with_underscore.style = underscore_prefix
+
+dotnet_naming_symbols.private_fields.applicable_kinds = field
+dotnet_naming_symbols.private_fields.applicable_accessibilities = private
+dotnet_naming_symbols.private_fields.required_modifiers = instance
+
+dotnet_naming_style.underscore_prefix.required_prefix = _
+dotnet_naming_style.underscore_prefix.capitalization = camel_case
+
+# Symbol specifications
+
+dotnet_naming_symbols.interface.applicable_kinds = interface
+dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.interface.required_modifiers =
+
+dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum
+dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.types.required_modifiers =
+
+dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
+dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.non_field_members.required_modifiers =
+
+# Naming styles
+
+dotnet_naming_style.begins_with_i.required_prefix = I
+dotnet_naming_style.begins_with_i.required_suffix =
+dotnet_naming_style.begins_with_i.word_separator =
+dotnet_naming_style.begins_with_i.capitalization = pascal_case
+
+dotnet_naming_style.pascal_case.required_prefix =
+dotnet_naming_style.pascal_case.required_suffix =
+dotnet_naming_style.pascal_case.word_separator =
+dotnet_naming_style.pascal_case.capitalization = pascal_case
+
+dotnet_naming_style.pascal_case.required_prefix =
+dotnet_naming_style.pascal_case.required_suffix =
+dotnet_naming_style.pascal_case.word_separator =
+dotnet_naming_style.pascal_case.capitalization = pascal_case
+dotnet_style_coalesce_expression = true:suggestion
+dotnet_style_null_propagation = true:suggestion
+dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
+dotnet_style_prefer_auto_properties = true:silent
+dotnet_style_object_initializer = true:suggestion
+dotnet_style_collection_initializer = true:suggestion
+dotnet_style_prefer_simplified_boolean_expressions = true:suggestion
+dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion
+dotnet_style_prefer_conditional_expression_over_return = true:suggestion
+dotnet_style_explicit_tuple_names = true:suggestion
+dotnet_style_prefer_inferred_tuple_names = true:suggestion
+dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
+dotnet_style_prefer_compound_assignment = true:suggestion
+dotnet_style_prefer_simplified_interpolation = true:suggestion
+dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion
+dotnet_style_namespace_match_folder = true:suggestion
+dotnet_style_operator_placement_when_wrapping = beginning_of_line
+tab_width = 4
+indent_size = 4
+end_of_line = crlf
+max_line_length = 220
+dotnet_style_readonly_field = true:suggestion
+dotnet_style_predefined_type_for_locals_parameters_members = true:silent
+dotnet_style_predefined_type_for_member_access = true:silent
+dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent
+dotnet_style_allow_statement_immediately_after_block_experimental = true:silent
+dotnet_style_allow_multiple_blank_lines_experimental = true:silent
+dotnet_style_namespace_declarations = file_scoped:suggestion
+dotnet_code_quality_unused_parameters = all:warning
+dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent
+dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent
+dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent
+dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent
+dotnet_style_qualification_for_field = false:warning
+dotnet_style_qualification_for_property = false:suggestion
+dotnet_style_qualification_for_method = false:warning
+dotnet_style_qualification_for_event = false:warning
+
+[*.cs]
+csharp_using_directive_placement = outside_namespace:silent
+csharp_prefer_simple_using_statement = true:suggestion
+csharp_prefer_braces = true:silent
+csharp_style_namespace_declarations = block_scoped:silent
+csharp_style_prefer_method_group_conversion = true:silent
+csharp_style_prefer_top_level_statements = true:silent
+csharp_style_prefer_primary_constructors = false:warning
+csharp_prefer_system_threading_lock = true:suggestion
+csharp_style_expression_bodied_methods = false:silent
+csharp_style_expression_bodied_constructors = false:silent
+csharp_style_expression_bodied_operators = false:silent
+csharp_style_expression_bodied_properties = true:silent
+csharp_style_expression_bodied_indexers = true:silent
+csharp_style_expression_bodied_accessors = true:silent
+csharp_style_expression_bodied_lambdas = true:silent
+csharp_style_expression_bodied_local_functions = false:silent
+csharp_style_throw_expression = true:suggestion
+csharp_style_prefer_null_check_over_type_check = true:suggestion
+csharp_prefer_simple_default_expression = true:suggestion
+csharp_style_prefer_local_over_anonymous_function = true:suggestion
+csharp_style_prefer_index_operator = true:suggestion
+csharp_style_prefer_range_operator = true:suggestion
+csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion
+csharp_style_prefer_tuple_swap = true:suggestion
+csharp_indent_labels = one_less_than_current
+csharp_space_around_binary_operators = before_and_after
+csharp_style_prefer_unbound_generic_type_in_nameof = true:suggestion
+csharp_style_prefer_utf8_string_literals = true:suggestion
+csharp_style_inlined_variable_declaration = true:suggestion
+csharp_style_deconstructed_variable_declaration = true:suggestion
+csharp_style_unused_value_assignment_preference = discard_variable:suggestion
+csharp_style_unused_value_expression_statement_preference = discard_variable:silent
+csharp_prefer_static_local_function = true:suggestion
+csharp_prefer_static_anonymous_function = true:suggestion
+csharp_style_prefer_readonly_struct = true:suggestion
+csharp_style_prefer_readonly_struct_member = true:suggestion
+csharp_style_allow_embedded_statements_on_same_line_experimental = true:silent
+csharp_style_allow_blank_lines_between_consecutive_braces_experimental = true:silent
+csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true:silent
+csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = true:silent
+csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true:silent
+csharp_style_conditional_delegate_call = true:suggestion
+csharp_style_prefer_switch_expression = true:suggestion
+csharp_style_prefer_pattern_matching = true:silent
+csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
+csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
+csharp_style_prefer_not_pattern = true:suggestion
+csharp_style_prefer_extended_property_pattern = true:suggestion
+csharp_style_var_for_built_in_types = false:silent
+csharp_style_var_when_type_is_apparent = false:silent
+csharp_style_var_elsewhere = false:silent
+csharp_style_prefer_implicitly_typed_lambda_expression = true:suggestion
+csharp_style_target_typed_new_expression = true:suggestion
diff --git a/MicCheck.slnx b/MicCheck.slnx
index 6d17c3b..2443264 100644
--- a/MicCheck.slnx
+++ b/MicCheck.slnx
@@ -1,10 +1,8 @@
-
-
diff --git a/docs/06_admin_api_output.md b/docs/06_admin_api_output.md
new file mode 100644
index 0000000..b5f6d02
--- /dev/null
+++ b/docs/06_admin_api_output.md
@@ -0,0 +1,237 @@
+# Step 6 Output: Admin API
+
+## Summary
+
+Implemented the Admin API — authenticated management endpoints for organizations, projects, environments, features, segments, identities, tags, webhooks, and audit logs. Authenticated via `Authorization: Api-Key ` or JWT bearer token.
+
+---
+
+## What Was Built
+
+### Shared Utilities
+
+**`MicCheck.Api/Common/DomainException.cs`**
+- Custom exception for business rule violations (feature limit, segment limit, etc.)
+- Caught in controllers and returned as `400 BadRequest`
+
+**`MicCheck.Api/Common/PaginatedResponse.cs`**
+- Generic `PaginatedResponse(Count, Next, Previous, Results)` record used by all list endpoints
+
+---
+
+### Audit
+
+**`MicCheck.Api/Audit/AuditService.cs`**
+- `virtual LogAsync(resourceType, resourceId, action, organizationId, projectId?, environmentId?, changes?, ct)` — persists audit log entry, extracts actor user ID from `UserId` claim via `IHttpContextAccessor`
+
+**`MicCheck.Api/Audit/AuditLogQueryService.cs`**
+- `ListByOrganizationAsync`, `ListByProjectAsync`, `ListByEnvironmentAsync` — ordered by descending `CreatedAt`
+
+**`MicCheck.Api/Audit/AuditLogResponse.cs`**
+- Static `From(AuditLog)` factory
+
+**`MicCheck.Api/Audit/AuditLogsController.cs`**
+- `GET /api/v1/organisations/{id}/audit-logs`
+- `GET /api/v1/projects/{projectId}/audit-logs`
+- (Environment audit logs handled in `EnvironmentsController`)
+
+---
+
+### Organizations
+
+**`MicCheck.Api/Organizations/OrganizationService.cs`**
+- `ListForUserAsync(userId)` — returns orgs where user is a member
+- `CreateAsync(name, creatorUserId)` — auto-adds creator as Admin member
+- `UpdateAsync`, `DeleteAsync`, `FindByIdAsync`
+- `ListMembersAsync`, `InviteUserAsync` (upsert), `RemoveMemberAsync`
+
+**`MicCheck.Api/Organizations/OrganizationsController.cs`**
+Routes:
+- `GET /api/v1/organisations/` — paginated, filtered to calling user's orgs
+- `POST /api/v1/organisations/`
+- `GET /api/v1/organisations/{id}/`
+- `PUT /api/v1/organisations/{id}/`
+- `DELETE /api/v1/organisations/{id}/`
+- `GET /api/v1/organisations/{id}/users/`
+- `POST /api/v1/organisations/{id}/users/invite/`
+- `DELETE /api/v1/organisations/{id}/users/{userId}/`
+
+---
+
+### Projects
+
+**`MicCheck.Api/Projects/ProjectService.cs`**
+- `ListByOrganizationAsync`, `FindByIdAsync`, `CreateAsync`, `UpdateAsync`, `DeleteAsync`
+- `ListUserPermissionsAsync`, `SetUserPermissionsAsync` (upsert), `RemoveUserPermissionsAsync`
+
+**`MicCheck.Api/Projects/ProjectsController.cs`**
+Routes:
+- `GET /api/v1/projects/?organizationId=`
+- `POST /api/v1/projects/`
+- `GET /api/v1/projects/{id}/`
+- `PUT /api/v1/projects/{id}/`
+- `DELETE /api/v1/projects/{id}/`
+- `GET /api/v1/projects/{id}/user-permissions/`
+- `POST /api/v1/projects/{id}/user-permissions/`
+- `PUT /api/v1/projects/{id}/user-permissions/{userId}/`
+- `DELETE /api/v1/projects/{id}/user-permissions/{userId}/`
+
+---
+
+### Environments
+
+**`MicCheck.Api/Environments/EnvironmentService.cs`**
+- `CreateAsync` — generates a random API key via `ApiKeyHasher.GenerateKey()`, auto-creates `FeatureState` for all existing features
+- `CloneAsync` — duplicates environment-level `FeatureState` records only (not identity or segment overrides)
+- `UpdateAsync`, `DeleteAsync`, `FindByApiKeyAsync`, `ListByProjectAsync`
+
+**`MicCheck.Api/Environments/EnvironmentsController.cs`**
+Routes:
+- `GET /api/v1/environments/?projectId=`
+- `POST /api/v1/environments/`
+- `GET /api/v1/environments/{apiKey}/`
+- `PUT /api/v1/environments/{apiKey}/`
+- `DELETE /api/v1/environments/{apiKey}/`
+- `POST /api/v1/environments/{apiKey}/clone/`
+- `GET /api/v1/environments/{apiKey}/webhooks/`
+- `POST /api/v1/environments/{apiKey}/webhooks/`
+- `PUT /api/v1/environments/{apiKey}/webhooks/{id}/`
+- `DELETE /api/v1/environments/{apiKey}/webhooks/{id}/`
+- `GET /api/v1/environments/{apiKey}/audit-logs/`
+- `GET /api/v1/environments/{apiKey}/identities/` (paginated)
+- `GET /api/v1/environments/{apiKey}/identities/{id}/`
+- `DELETE /api/v1/environments/{apiKey}/identities/{id}/`
+- `GET /api/v1/environments/{apiKey}/identities/{id}/featurestates/`
+- `PUT /api/v1/environments/{apiKey}/identities/{id}/featurestates/{featureId}/`
+- `DELETE /api/v1/environments/{apiKey}/identities/{id}/featurestates/{featureId}/`
+- `GET /api/v1/environments/{apiKey}/featurestates/`
+- `GET /api/v1/environments/{apiKey}/featurestates/{id}/`
+- `PUT /api/v1/environments/{apiKey}/featurestates/{id}/`
+- `PATCH /api/v1/environments/{apiKey}/featurestates/{id}/`
+
+---
+
+### Features
+
+**`MicCheck.Api/Features/FeatureService.cs`**
+- `CreateAsync` — enforces 400-feature limit (`DomainException`), auto-creates `FeatureState` for all project environments
+- `UpdateAsync`, `DeleteAsync`, `FindByIdAsync`, `ListByProjectAsync`
+
+**`MicCheck.Api/Features/FeatureStateService.cs`**
+- `ListByEnvironmentAsync` — environment-level states only (no identity/segment overrides)
+- `FindByIdAsync`, `UpdateAsync` (full replace), `PatchAsync` (partial)
+
+**`MicCheck.Api/Features/FeaturesController.cs`**
+Routes under `api/v1/projects/{projectId}/features/`:
+- `GET`, `POST`, `GET {id}`, `PUT {id}`, `PATCH {id}`, `DELETE {id}`
+
+---
+
+### Segments
+
+**`MicCheck.Api/Segments/SegmentService.cs`**
+- `CreateAsync` — enforces 100-segment limit, 100-condition limit (across all rules recursively)
+- `UpdateAsync` — replaces all rules and conditions (full replace semantics)
+- `DeleteAsync`, `FindByIdAsync`, `ListByProjectAsync`
+
+**`MicCheck.Api/Segments/SegmentsController.cs`**
+Routes under `api/v1/projects/{projectId}/segments/`:
+- `GET`, `POST`, `GET {id}`, `PUT {id}`, `DELETE {id}`
+
+---
+
+### Admin Identities
+
+**`MicCheck.Api/Identities/AdminIdentityService.cs`**
+- `ListAsync` (paginated), `FindByIdAsync`, `DeleteAsync`
+- `GetFeatureStatesAsync`, `SetFeatureStateAsync` (upsert), `DeleteFeatureStateAsync`
+
+---
+
+### Tags
+
+**`MicCheck.Api/Features/TagService.cs`** — list, create, delete
+
+**`MicCheck.Api/Features/TagsController.cs`**
+Routes under `api/v1/projects/{projectId}/tags/`:
+- `GET`, `POST`, `DELETE {id}`
+
+---
+
+### Webhooks
+
+**`MicCheck.Api/Webhooks/WebhookService.cs`** — list by environment, create, update, delete
+
+---
+
+## Request Validation
+
+All incoming DTOs have FluentValidation validators in the same folder. Validation errors return **422 Unprocessable Entity** with field-level detail (configured via `ApiBehaviorOptions`).
+
+Key validation rules:
+- Feature names: `^[a-zA-Z0-9_-]+$`, max 150 chars
+- Feature `InitialValue`: max 20,000 chars
+- Webhook URLs: must be absolute URI
+- Tag colors: hex format (`#RGB` or `#RRGGBB`)
+- Segment rule `Type`: `All`, `Any`, or `None`
+- Segment condition `Operator`: any of the 17 `SegmentConditionOperator` enum values
+
+---
+
+## Program.cs Changes
+
+- Added `JsonStringEnumConverter` to MVC JSON options (allows `"Standard"` instead of `0` in API requests)
+- Added `Configure` to return 422 with field-level errors for validation failures
+- Registered new services: `AuditService`, `AuditLogQueryService`, `OrganizationService`, `ProjectService`, `EnvironmentService`, `FeatureService`, `FeatureStateService`, `SegmentService`, `TagService`, `WebhookService`, `AdminIdentityService`
+
+---
+
+## Deviations from Plan
+
+- Identity admin endpoints are nested under `/api/v1/environments/{apiKey}/` (as specified in the plan) and handled in `EnvironmentsController` via `[FromServices]` to avoid constructor over-injection
+- Feature state endpoints (`/api/v1/environments/{apiKey}/featurestates/`) are also in `EnvironmentsController` for the same reason
+- `AuditService.LogAsync` is marked `virtual` to support Moq in service unit tests
+
+---
+
+## Tests Added
+
+### `FeatureServiceTests` (6 tests, in-memory DB)
+- Creating a feature auto-creates `FeatureState` for each environment
+- `InitialValue` is propagated to `FeatureState.Value`
+- 400-feature limit throws `DomainException`
+- Update changes name and description
+- Delete removes the feature
+- List returns all project features
+
+### `EnvironmentServiceTests` (5 tests, in-memory DB)
+- Creating an environment auto-creates `FeatureState` for each existing feature
+- Each created environment gets a unique API key
+- Clone copies environment-level feature states
+- Clone does NOT copy identity overrides
+- Clone generates a different API key from source
+
+### `SegmentServiceTests` (6 tests, in-memory DB)
+- Create persists segment, rules, and conditions
+- 100-segment limit throws `DomainException`
+- 100-condition limit throws `DomainException`
+- Update replaces old rules with new rules
+- Delete removes segment
+- List returns all project segments
+
+### `AdminApiIntegrationTests` (7 tests, WebApplicationFactory)
+- Creating a project returns 201
+- Creating a feature returns 201
+- Creating a feature auto-creates `FeatureState` for environments
+- Creating an environment auto-creates `FeatureState` for existing features
+- Missing API key returns 401
+- Invalid feature name (with spaces) returns 422
+- Creating a segment returns 201
+
+---
+
+## Test Results
+
+```
+Passed! - Failed: 0, Passed: 166, Skipped: 0, Total: 166
+```
diff --git a/src/api/MicCheck.Api/ApiKeys/.gitkeep b/src/api/MicCheck.Api/ApiKeys/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/src/api/MicCheck.Api/Audit/.gitkeep b/src/api/MicCheck.Api/Audit/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/src/api/MicCheck.Api/Audit/AuditLogQueryService.cs b/src/api/MicCheck.Api/Audit/AuditLogQueryService.cs
new file mode 100644
index 0000000..9272b0e
--- /dev/null
+++ b/src/api/MicCheck.Api/Audit/AuditLogQueryService.cs
@@ -0,0 +1,31 @@
+using MicCheck.Api.Data;
+using Microsoft.EntityFrameworkCore;
+
+namespace MicCheck.Api.Audit;
+
+public class AuditLogQueryService(MicCheckDbContext db)
+{
+ public async Task> ListByOrganizationAsync(int organizationId, CancellationToken ct = default)
+ {
+ return await db.AuditLogs
+ .Where(l => l.OrganizationId == organizationId)
+ .OrderByDescending(l => l.CreatedAt)
+ .ToListAsync(ct);
+ }
+
+ public async Task> ListByProjectAsync(int projectId, CancellationToken ct = default)
+ {
+ return await db.AuditLogs
+ .Where(l => l.ProjectId == projectId)
+ .OrderByDescending(l => l.CreatedAt)
+ .ToListAsync(ct);
+ }
+
+ public async Task> ListByEnvironmentAsync(int environmentId, CancellationToken ct = default)
+ {
+ return await db.AuditLogs
+ .Where(l => l.EnvironmentId == environmentId)
+ .OrderByDescending(l => l.CreatedAt)
+ .ToListAsync(ct);
+ }
+}
diff --git a/src/api/MicCheck.Api/Audit/AuditLogResponse.cs b/src/api/MicCheck.Api/Audit/AuditLogResponse.cs
new file mode 100644
index 0000000..3f89e9b
--- /dev/null
+++ b/src/api/MicCheck.Api/Audit/AuditLogResponse.cs
@@ -0,0 +1,27 @@
+namespace MicCheck.Api.Audit;
+
+public record AuditLogResponse(
+ int Id,
+ string ResourceType,
+ string ResourceId,
+ string Action,
+ string? Changes,
+ int OrganizationId,
+ int? ProjectId,
+ int? EnvironmentId,
+ int? ActorUserId,
+ DateTimeOffset CreatedAt
+)
+{
+ public static AuditLogResponse From(AuditLog log) => new(
+ log.Id,
+ log.ResourceType,
+ log.ResourceId,
+ log.Action,
+ log.Changes,
+ log.OrganizationId,
+ log.ProjectId,
+ log.EnvironmentId,
+ log.ActorUserId,
+ log.CreatedAt);
+}
diff --git a/src/api/MicCheck.Api/Audit/AuditLogsController.cs b/src/api/MicCheck.Api/Audit/AuditLogsController.cs
new file mode 100644
index 0000000..9d85f00
--- /dev/null
+++ b/src/api/MicCheck.Api/Audit/AuditLogsController.cs
@@ -0,0 +1,37 @@
+using MicCheck.Api.Authorization;
+using MicCheck.Api.Projects;
+using MicCheck.Api.Organizations;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.RateLimiting;
+
+namespace MicCheck.Api.Audit;
+
+[ApiController]
+[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
+[EnableRateLimiting("AdminApi")]
+public class AuditLogsController(
+ AuditLogQueryService auditLogQueryService,
+ OrganizationService organizationService,
+ ProjectService projectService) : ControllerBase
+{
+ [HttpGet("api/v1/organisations/{id}/audit-logs")]
+ public async Task>> ListByOrganization(int id, CancellationToken ct)
+ {
+ var org = await organizationService.FindByIdAsync(id, ct);
+ if (org is null) return NotFound();
+
+ var logs = await auditLogQueryService.ListByOrganizationAsync(id, ct);
+ return Ok(logs.Select(AuditLogResponse.From).ToList());
+ }
+
+ [HttpGet("api/v1/projects/{projectId}/audit-logs")]
+ public async Task>> ListByProject(int projectId, CancellationToken ct)
+ {
+ var project = await projectService.FindByIdAsync(projectId, ct);
+ if (project is null) return NotFound();
+
+ var logs = await auditLogQueryService.ListByProjectAsync(projectId, ct);
+ return Ok(logs.Select(AuditLogResponse.From).ToList());
+ }
+}
diff --git a/src/api/MicCheck.Api/Audit/AuditService.cs b/src/api/MicCheck.Api/Audit/AuditService.cs
new file mode 100644
index 0000000..0ce5831
--- /dev/null
+++ b/src/api/MicCheck.Api/Audit/AuditService.cs
@@ -0,0 +1,38 @@
+using MicCheck.Api.Data;
+
+namespace MicCheck.Api.Audit;
+
+public class AuditService(MicCheckDbContext db, IHttpContextAccessor httpContextAccessor)
+{
+ public virtual async Task LogAsync(
+ string resourceType,
+ string resourceId,
+ string action,
+ int organizationId,
+ int? projectId = null,
+ int? environmentId = null,
+ string? changes = null,
+ CancellationToken ct = default)
+ {
+ int? actorUserId = null;
+
+ var userIdClaim = httpContextAccessor.HttpContext?.User.FindFirst("UserId")?.Value;
+ if (int.TryParse(userIdClaim, out var parsedId))
+ actorUserId = parsedId;
+
+ db.AuditLogs.Add(new AuditLog
+ {
+ ResourceType = resourceType,
+ ResourceId = resourceId,
+ Action = action,
+ OrganizationId = organizationId,
+ ProjectId = projectId,
+ EnvironmentId = environmentId,
+ ActorUserId = actorUserId,
+ Changes = changes,
+ CreatedAt = DateTimeOffset.UtcNow
+ });
+
+ await db.SaveChangesAsync(ct);
+ }
+}
diff --git a/src/api/MicCheck.Api/Common/DomainException.cs b/src/api/MicCheck.Api/Common/DomainException.cs
new file mode 100644
index 0000000..0f2327f
--- /dev/null
+++ b/src/api/MicCheck.Api/Common/DomainException.cs
@@ -0,0 +1,3 @@
+namespace MicCheck.Api.Common;
+
+public class DomainException(string message) : Exception(message);
diff --git a/src/api/MicCheck.Api/Common/PaginatedResponse.cs b/src/api/MicCheck.Api/Common/PaginatedResponse.cs
new file mode 100644
index 0000000..07c72aa
--- /dev/null
+++ b/src/api/MicCheck.Api/Common/PaginatedResponse.cs
@@ -0,0 +1,8 @@
+namespace MicCheck.Api.Common;
+
+public record PaginatedResponse(
+ int Count,
+ string? Next,
+ string? Previous,
+ IReadOnlyList Results
+);
diff --git a/src/api/MicCheck.Api/Environments/.gitkeep b/src/api/MicCheck.Api/Environments/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/src/api/MicCheck.Api/Environments/CloneEnvironmentRequest.cs b/src/api/MicCheck.Api/Environments/CloneEnvironmentRequest.cs
new file mode 100644
index 0000000..ba8b0aa
--- /dev/null
+++ b/src/api/MicCheck.Api/Environments/CloneEnvironmentRequest.cs
@@ -0,0 +1,13 @@
+using FluentValidation;
+
+namespace MicCheck.Api.Environments;
+
+public record CloneEnvironmentRequest(string Name);
+
+public class CloneEnvironmentRequestValidator : AbstractValidator
+{
+ public CloneEnvironmentRequestValidator()
+ {
+ RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
+ }
+}
diff --git a/src/api/MicCheck.Api/Environments/CreateEnvironmentRequest.cs b/src/api/MicCheck.Api/Environments/CreateEnvironmentRequest.cs
new file mode 100644
index 0000000..d8ac7c9
--- /dev/null
+++ b/src/api/MicCheck.Api/Environments/CreateEnvironmentRequest.cs
@@ -0,0 +1,14 @@
+using FluentValidation;
+
+namespace MicCheck.Api.Environments;
+
+public record CreateEnvironmentRequest(string Name, int ProjectId);
+
+public class CreateEnvironmentRequestValidator : AbstractValidator
+{
+ public CreateEnvironmentRequestValidator()
+ {
+ RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
+ RuleFor(x => x.ProjectId).GreaterThan(0);
+ }
+}
diff --git a/src/api/MicCheck.Api/Environments/EnvironmentResponse.cs b/src/api/MicCheck.Api/Environments/EnvironmentResponse.cs
new file mode 100644
index 0000000..aa4814b
--- /dev/null
+++ b/src/api/MicCheck.Api/Environments/EnvironmentResponse.cs
@@ -0,0 +1,15 @@
+using AppEnvironment = MicCheck.Api.Environments.Environment;
+
+namespace MicCheck.Api.Environments;
+
+public record EnvironmentResponse(
+ int Id,
+ string Name,
+ string ApiKey,
+ int ProjectId,
+ DateTimeOffset CreatedAt
+)
+{
+ public static EnvironmentResponse From(AppEnvironment env) => new(
+ env.Id, env.Name, env.ApiKey, env.ProjectId, env.CreatedAt);
+}
diff --git a/src/api/MicCheck.Api/Environments/EnvironmentService.cs b/src/api/MicCheck.Api/Environments/EnvironmentService.cs
new file mode 100644
index 0000000..6afa325
--- /dev/null
+++ b/src/api/MicCheck.Api/Environments/EnvironmentService.cs
@@ -0,0 +1,135 @@
+using MicCheck.Api.ApiKeys;
+using MicCheck.Api.Audit;
+using MicCheck.Api.Data;
+using MicCheck.Api.Features;
+using Microsoft.EntityFrameworkCore;
+using AppEnvironment = MicCheck.Api.Environments.Environment;
+
+namespace MicCheck.Api.Environments;
+
+public class EnvironmentService(MicCheckDbContext db, AuditService auditService)
+{
+ public async Task> ListByProjectAsync(int projectId, CancellationToken ct = default)
+ {
+ return await db.Environments
+ .Where(e => e.ProjectId == projectId)
+ .ToListAsync(ct);
+ }
+
+ public async Task FindByApiKeyAsync(string apiKey, CancellationToken ct = default)
+ {
+ return await db.Environments.FirstOrDefaultAsync(e => e.ApiKey == apiKey, ct);
+ }
+
+ public async Task CreateAsync(int projectId, string name, CancellationToken ct = default)
+ {
+ var apiKey = ApiKeyHasher.GenerateKey();
+
+ var environment = new AppEnvironment
+ {
+ Name = name,
+ ApiKey = apiKey,
+ ProjectId = projectId,
+ CreatedAt = DateTimeOffset.UtcNow
+ };
+ db.Environments.Add(environment);
+ await db.SaveChangesAsync(ct);
+
+ var features = await db.Features
+ .Where(f => f.ProjectId == projectId)
+ .ToListAsync(ct);
+
+ foreach (var feature in features)
+ {
+ db.FeatureStates.Add(new FeatureState
+ {
+ FeatureId = feature.Id,
+ EnvironmentId = environment.Id,
+ Enabled = feature.DefaultEnabled,
+ Value = feature.InitialValue,
+ CreatedAt = DateTimeOffset.UtcNow,
+ UpdatedAt = DateTimeOffset.UtcNow
+ });
+ }
+
+ if (features.Count > 0)
+ await db.SaveChangesAsync(ct);
+
+ var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == projectId, ct);
+ if (project is not null)
+ await auditService.LogAsync("Environment", environment.Id.ToString(), "created",
+ project.OrganizationId, projectId, environment.Id, ct: ct);
+
+ return environment;
+ }
+
+ public async Task UpdateAsync(string apiKey, string name, CancellationToken ct = default)
+ {
+ var environment = await db.Environments.FirstOrDefaultAsync(e => e.ApiKey == apiKey, ct)
+ ?? throw new KeyNotFoundException($"Environment with key '{apiKey}' not found.");
+
+ environment.Name = name;
+ await db.SaveChangesAsync(ct);
+
+ var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == environment.ProjectId, ct);
+ if (project is not null)
+ await auditService.LogAsync("Environment", environment.Id.ToString(), "updated",
+ project.OrganizationId, environment.ProjectId, environment.Id, ct: ct);
+
+ return environment;
+ }
+
+ public async Task DeleteAsync(string apiKey, CancellationToken ct = default)
+ {
+ var environment = await db.Environments.FirstOrDefaultAsync(e => e.ApiKey == apiKey, ct);
+ if (environment is null) return;
+
+ db.Environments.Remove(environment);
+ await db.SaveChangesAsync(ct);
+ }
+
+ public async Task CloneAsync(string sourceApiKey, string newName, CancellationToken ct = default)
+ {
+ var source = await db.Environments.FirstOrDefaultAsync(e => e.ApiKey == sourceApiKey, ct)
+ ?? throw new KeyNotFoundException($"Source environment with key '{sourceApiKey}' not found.");
+
+ var newApiKey = ApiKeyHasher.GenerateKey();
+
+ var cloned = new AppEnvironment
+ {
+ Name = newName,
+ ApiKey = newApiKey,
+ ProjectId = source.ProjectId,
+ CreatedAt = DateTimeOffset.UtcNow
+ };
+ db.Environments.Add(cloned);
+ await db.SaveChangesAsync(ct);
+
+ var sourceStates = await db.FeatureStates
+ .Where(fs => fs.EnvironmentId == source.Id && fs.IdentityId == null && fs.FeatureSegmentId == null)
+ .ToListAsync(ct);
+
+ foreach (var state in sourceStates)
+ {
+ db.FeatureStates.Add(new FeatureState
+ {
+ FeatureId = state.FeatureId,
+ EnvironmentId = cloned.Id,
+ Enabled = state.Enabled,
+ Value = state.Value,
+ CreatedAt = DateTimeOffset.UtcNow,
+ UpdatedAt = DateTimeOffset.UtcNow
+ });
+ }
+
+ if (sourceStates.Count > 0)
+ await db.SaveChangesAsync(ct);
+
+ var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == source.ProjectId, ct);
+ if (project is not null)
+ await auditService.LogAsync("Environment", cloned.Id.ToString(), "cloned",
+ project.OrganizationId, source.ProjectId, cloned.Id, ct: ct);
+
+ return cloned;
+ }
+}
diff --git a/src/api/MicCheck.Api/Environments/EnvironmentsController.cs b/src/api/MicCheck.Api/Environments/EnvironmentsController.cs
new file mode 100644
index 0000000..1bd3d6c
--- /dev/null
+++ b/src/api/MicCheck.Api/Environments/EnvironmentsController.cs
@@ -0,0 +1,291 @@
+using MicCheck.Api.Authorization;
+using MicCheck.Api.Common;
+using MicCheck.Api.Webhooks;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.RateLimiting;
+
+namespace MicCheck.Api.Environments;
+
+[ApiController]
+[Route("api/v1/environments")]
+[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
+[EnableRateLimiting("AdminApi")]
+public class EnvironmentsController(EnvironmentService environmentService, WebhookService webhookService) : ControllerBase
+{
+ [HttpGet]
+ public async Task>> List(
+ [FromQuery] int projectId,
+ [FromQuery] int page = 1,
+ [FromQuery] int pageSize = 20,
+ CancellationToken ct = default)
+ {
+ pageSize = Math.Clamp(pageSize, 1, 100);
+ var all = await environmentService.ListByProjectAsync(projectId, ct);
+ var paged = all.Skip((page - 1) * pageSize).Take(pageSize).Select(EnvironmentResponse.From).ToList();
+ return Ok(new PaginatedResponse(all.Count, null, null, paged));
+ }
+
+ [HttpPost]
+ public async Task> Create(CreateEnvironmentRequest request, CancellationToken ct)
+ {
+ var environment = await environmentService.CreateAsync(request.ProjectId, request.Name, ct);
+ return CreatedAtAction(nameof(GetByApiKey), new { apiKey = environment.ApiKey }, EnvironmentResponse.From(environment));
+ }
+
+ [HttpGet("{apiKey}")]
+ public async Task> GetByApiKey(string apiKey, CancellationToken ct)
+ {
+ var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
+ if (environment is null) return NotFound();
+ return Ok(EnvironmentResponse.From(environment));
+ }
+
+ [HttpPut("{apiKey}")]
+ public async Task> Update(string apiKey, UpdateEnvironmentRequest request, CancellationToken ct)
+ {
+ var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
+ if (environment is null) return NotFound();
+
+ var updated = await environmentService.UpdateAsync(apiKey, request.Name, ct);
+ return Ok(EnvironmentResponse.From(updated));
+ }
+
+ [HttpDelete("{apiKey}")]
+ public async Task Delete(string apiKey, CancellationToken ct)
+ {
+ var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
+ if (environment is null) return NotFound();
+
+ await environmentService.DeleteAsync(apiKey, ct);
+ return NoContent();
+ }
+
+ [HttpPost("{apiKey}/clone")]
+ public async Task> Clone(string apiKey, CloneEnvironmentRequest request, CancellationToken ct)
+ {
+ var source = await environmentService.FindByApiKeyAsync(apiKey, ct);
+ if (source is null) return NotFound();
+
+ var cloned = await environmentService.CloneAsync(apiKey, request.Name, ct);
+ return CreatedAtAction(nameof(GetByApiKey), new { apiKey = cloned.ApiKey }, EnvironmentResponse.From(cloned));
+ }
+
+ [HttpGet("{apiKey}/webhooks")]
+ public async Task>> ListWebhooks(string apiKey, CancellationToken ct)
+ {
+ var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
+ if (environment is null) return NotFound();
+
+ var webhooks = await webhookService.ListByEnvironmentAsync(environment.Id, ct);
+ return Ok(webhooks.Select(WebhookResponse.From).ToList());
+ }
+
+ [HttpPost("{apiKey}/webhooks")]
+ public async Task> CreateWebhook(
+ string apiKey, CreateWebhookRequest request, CancellationToken ct)
+ {
+ var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
+ if (environment is null) return NotFound();
+
+ var webhook = await webhookService.CreateAsync(environment.Id, request.Url, request.Secret, request.Enabled, ct);
+ return CreatedAtAction(nameof(GetByApiKey), new { apiKey }, WebhookResponse.From(webhook));
+ }
+
+ [HttpPut("{apiKey}/webhooks/{id}")]
+ public async Task> UpdateWebhook(
+ string apiKey, int id, CreateWebhookRequest request, CancellationToken ct)
+ {
+ var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
+ if (environment is null) return NotFound();
+
+ var webhook = await webhookService.FindByIdAsync(id, ct);
+ if (webhook is null || webhook.EnvironmentId != environment.Id) return NotFound();
+
+ var updated = await webhookService.UpdateAsync(id, request.Url, request.Secret, request.Enabled, ct);
+ return Ok(WebhookResponse.From(updated));
+ }
+
+ [HttpDelete("{apiKey}/webhooks/{id}")]
+ public async Task DeleteWebhook(string apiKey, int id, CancellationToken ct)
+ {
+ var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
+ if (environment is null) return NotFound();
+
+ var webhook = await webhookService.FindByIdAsync(id, ct);
+ if (webhook is null || webhook.EnvironmentId != environment.Id) return NotFound();
+
+ await webhookService.DeleteAsync(id, ct);
+ return NoContent();
+ }
+
+ [HttpGet("{apiKey}/audit-logs")]
+ public async Task>> ListAuditLogs(
+ string apiKey,
+ [FromServices] Audit.AuditLogQueryService auditLogQueryService,
+ CancellationToken ct)
+ {
+ var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
+ if (environment is null) return NotFound();
+
+ var logs = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, ct);
+ return Ok(logs.Select(Audit.AuditLogResponse.From).ToList());
+ }
+
+ [HttpGet("{apiKey}/identities")]
+ public async Task>> ListIdentities(
+ string apiKey,
+ [FromServices] Identities.AdminIdentityService adminIdentityService,
+ [FromQuery] int page = 1,
+ [FromQuery] int pageSize = 20,
+ CancellationToken ct = default)
+ {
+ pageSize = Math.Clamp(pageSize, 1, 100);
+ var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
+ if (environment is null) return NotFound();
+
+ var (total, items) = await adminIdentityService.ListAsync(environment.Id, page, pageSize, ct);
+ var results = items.Select(Identities.AdminIdentityResponse.From).ToList();
+ return Ok(new PaginatedResponse(total, null, null, results));
+ }
+
+ [HttpGet("{apiKey}/identities/{id}")]
+ public async Task> GetIdentity(
+ string apiKey, int id,
+ [FromServices] Identities.AdminIdentityService adminIdentityService,
+ CancellationToken ct)
+ {
+ var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
+ if (environment is null) return NotFound();
+
+ var identity = await adminIdentityService.FindByIdAsync(id, environment.Id, ct);
+ if (identity is null) return NotFound();
+ return Ok(Identities.AdminIdentityResponse.From(identity));
+ }
+
+ [HttpDelete("{apiKey}/identities/{id}")]
+ public async Task DeleteIdentity(
+ string apiKey, int id,
+ [FromServices] Identities.AdminIdentityService adminIdentityService,
+ CancellationToken ct)
+ {
+ var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
+ if (environment is null) return NotFound();
+
+ var identity = await adminIdentityService.FindByIdAsync(id, environment.Id, ct);
+ if (identity is null) return NotFound();
+
+ await adminIdentityService.DeleteAsync(id, ct);
+ return NoContent();
+ }
+
+ [HttpGet("{apiKey}/identities/{id}/featurestates")]
+ public async Task>> GetIdentityFeatureStates(
+ string apiKey, int id,
+ [FromServices] Identities.AdminIdentityService adminIdentityService,
+ CancellationToken ct)
+ {
+ var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
+ if (environment is null) return NotFound();
+
+ var identity = await adminIdentityService.FindByIdAsync(id, environment.Id, ct);
+ if (identity is null) return NotFound();
+
+ var states = await adminIdentityService.GetFeatureStatesAsync(id, environment.Id, ct);
+ return Ok(states.Select(Features.FeatureStateResponse.From).ToList());
+ }
+
+ [HttpPut("{apiKey}/identities/{id}/featurestates/{featureId}")]
+ public async Task> SetIdentityFeatureState(
+ string apiKey, int id, int featureId,
+ Features.UpdateFeatureStateRequest request,
+ [FromServices] Identities.AdminIdentityService adminIdentityService,
+ CancellationToken ct)
+ {
+ var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
+ if (environment is null) return NotFound();
+
+ var identity = await adminIdentityService.FindByIdAsync(id, environment.Id, ct);
+ if (identity is null) return NotFound();
+
+ var state = await adminIdentityService.SetFeatureStateAsync(id, environment.Id, featureId, request.Enabled, request.Value, ct);
+ return Ok(Features.FeatureStateResponse.From(state));
+ }
+
+ [HttpDelete("{apiKey}/identities/{id}/featurestates/{featureId}")]
+ public async Task DeleteIdentityFeatureState(
+ string apiKey, int id, int featureId,
+ [FromServices] Identities.AdminIdentityService adminIdentityService,
+ CancellationToken ct)
+ {
+ var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
+ if (environment is null) return NotFound();
+
+ var identity = await adminIdentityService.FindByIdAsync(id, environment.Id, ct);
+ if (identity is null) return NotFound();
+
+ await adminIdentityService.DeleteFeatureStateAsync(id, environment.Id, featureId, ct);
+ return NoContent();
+ }
+
+ [HttpGet("{apiKey}/featurestates")]
+ public async Task>> ListFeatureStates(
+ string apiKey,
+ [FromServices] Features.FeatureStateService featureStateService,
+ CancellationToken ct)
+ {
+ var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
+ if (environment is null) return NotFound();
+
+ var states = await featureStateService.ListByEnvironmentAsync(environment.Id, ct);
+ return Ok(states.Select(Features.FeatureStateResponse.From).ToList());
+ }
+
+ [HttpGet("{apiKey}/featurestates/{id}")]
+ public async Task> GetFeatureState(
+ string apiKey, int id,
+ [FromServices] Features.FeatureStateService featureStateService,
+ CancellationToken ct)
+ {
+ var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
+ if (environment is null) return NotFound();
+
+ var state = await featureStateService.FindByIdAsync(id, environment.Id, ct);
+ if (state is null) return NotFound();
+ return Ok(Features.FeatureStateResponse.From(state));
+ }
+
+ [HttpPut("{apiKey}/featurestates/{id}")]
+ public async Task> UpdateFeatureState(
+ string apiKey, int id,
+ Features.UpdateFeatureStateRequest request,
+ [FromServices] Features.FeatureStateService featureStateService,
+ CancellationToken ct)
+ {
+ var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
+ if (environment is null) return NotFound();
+
+ var state = await featureStateService.FindByIdAsync(id, environment.Id, ct);
+ if (state is null) return NotFound();
+
+ var updated = await featureStateService.UpdateAsync(id, request.Enabled, request.Value, ct);
+ return Ok(Features.FeatureStateResponse.From(updated));
+ }
+
+ [HttpPatch("{apiKey}/featurestates/{id}")]
+ public async Task> PatchFeatureState(
+ string apiKey, int id,
+ Features.PatchFeatureStateRequest request,
+ [FromServices] Features.FeatureStateService featureStateService,
+ CancellationToken ct)
+ {
+ var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
+ if (environment is null) return NotFound();
+
+ var state = await featureStateService.FindByIdAsync(id, environment.Id, ct);
+ if (state is null) return NotFound();
+
+ var updated = await featureStateService.PatchAsync(id, request.Enabled, request.Value, ct);
+ return Ok(Features.FeatureStateResponse.From(updated));
+ }
+}
diff --git a/src/api/MicCheck.Api/Environments/UpdateEnvironmentRequest.cs b/src/api/MicCheck.Api/Environments/UpdateEnvironmentRequest.cs
new file mode 100644
index 0000000..e659afb
--- /dev/null
+++ b/src/api/MicCheck.Api/Environments/UpdateEnvironmentRequest.cs
@@ -0,0 +1,13 @@
+using FluentValidation;
+
+namespace MicCheck.Api.Environments;
+
+public record UpdateEnvironmentRequest(string Name);
+
+public class UpdateEnvironmentRequestValidator : AbstractValidator
+{
+ public UpdateEnvironmentRequestValidator()
+ {
+ RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
+ }
+}
diff --git a/src/api/MicCheck.Api/Features/.gitkeep b/src/api/MicCheck.Api/Features/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/src/api/MicCheck.Api/Features/CreateFeatureRequest.cs b/src/api/MicCheck.Api/Features/CreateFeatureRequest.cs
new file mode 100644
index 0000000..3c7ec70
--- /dev/null
+++ b/src/api/MicCheck.Api/Features/CreateFeatureRequest.cs
@@ -0,0 +1,26 @@
+using FluentValidation;
+
+namespace MicCheck.Api.Features;
+
+public record CreateFeatureRequest(
+ string Name,
+ FeatureType Type,
+ string? InitialValue,
+ string? Description
+);
+
+public class CreateFeatureRequestValidator : AbstractValidator
+{
+ public CreateFeatureRequestValidator()
+ {
+ RuleFor(x => x.Name)
+ .NotEmpty()
+ .MaximumLength(150)
+ .Matches("^[a-zA-Z0-9_-]+$")
+ .WithMessage("Name may only contain letters, digits, underscores, and hyphens.");
+
+ RuleFor(x => x.InitialValue)
+ .MaximumLength(20_000)
+ .When(x => x.InitialValue is not null);
+ }
+}
diff --git a/src/api/MicCheck.Api/Features/CreateTagRequest.cs b/src/api/MicCheck.Api/Features/CreateTagRequest.cs
new file mode 100644
index 0000000..705cb09
--- /dev/null
+++ b/src/api/MicCheck.Api/Features/CreateTagRequest.cs
@@ -0,0 +1,16 @@
+using FluentValidation;
+
+namespace MicCheck.Api.Features;
+
+public record CreateTagRequest(string Label, string Color);
+
+public class CreateTagRequestValidator : AbstractValidator
+{
+ public CreateTagRequestValidator()
+ {
+ RuleFor(x => x.Label).NotEmpty().MaximumLength(100);
+ RuleFor(x => x.Color).NotEmpty().MaximumLength(20)
+ .Matches("^#[0-9A-Fa-f]{3,6}$")
+ .WithMessage("Color must be a valid hex color (e.g. #FF0000).");
+ }
+}
diff --git a/src/api/MicCheck.Api/Features/FeatureResponse.cs b/src/api/MicCheck.Api/Features/FeatureResponse.cs
new file mode 100644
index 0000000..0b177d6
--- /dev/null
+++ b/src/api/MicCheck.Api/Features/FeatureResponse.cs
@@ -0,0 +1,23 @@
+namespace MicCheck.Api.Features;
+
+public record FeatureResponse(
+ int Id,
+ string Name,
+ string Type,
+ string? InitialValue,
+ string? Description,
+ bool DefaultEnabled,
+ int ProjectId,
+ DateTimeOffset CreatedAt
+)
+{
+ public static FeatureResponse From(Feature feature) => new(
+ feature.Id,
+ feature.Name,
+ feature.Type.ToString().ToUpperInvariant(),
+ feature.InitialValue,
+ feature.Description,
+ feature.DefaultEnabled,
+ feature.ProjectId,
+ feature.CreatedAt);
+}
diff --git a/src/api/MicCheck.Api/Features/FeatureService.cs b/src/api/MicCheck.Api/Features/FeatureService.cs
new file mode 100644
index 0000000..5b1e805
--- /dev/null
+++ b/src/api/MicCheck.Api/Features/FeatureService.cs
@@ -0,0 +1,102 @@
+using MicCheck.Api.Audit;
+using MicCheck.Api.Common;
+using MicCheck.Api.Data;
+using Microsoft.EntityFrameworkCore;
+
+namespace MicCheck.Api.Features;
+
+public class FeatureService(MicCheckDbContext db, AuditService auditService)
+{
+ private const int MaxFeaturesPerProject = 400;
+
+ public async Task> ListByProjectAsync(int projectId, CancellationToken ct = default)
+ {
+ return await db.Features
+ .Where(f => f.ProjectId == projectId)
+ .ToListAsync(ct);
+ }
+
+ public async Task FindByIdAsync(int id, CancellationToken ct = default)
+ {
+ return await db.Features.FirstOrDefaultAsync(f => f.Id == id, ct);
+ }
+
+ public async Task CreateAsync(
+ int projectId,
+ string name,
+ FeatureType type,
+ string? initialValue,
+ string? description,
+ CancellationToken ct = default)
+ {
+ var count = await db.Features.CountAsync(f => f.ProjectId == projectId, ct);
+ if (count >= MaxFeaturesPerProject)
+ throw new DomainException($"Project has reached the maximum of {MaxFeaturesPerProject} features.");
+
+ var feature = new Feature
+ {
+ Name = name,
+ Type = type,
+ InitialValue = initialValue,
+ Description = description,
+ ProjectId = projectId,
+ CreatedAt = DateTimeOffset.UtcNow
+ };
+ db.Features.Add(feature);
+ await db.SaveChangesAsync(ct);
+
+ var environments = await db.Environments
+ .Where(e => e.ProjectId == projectId)
+ .ToListAsync(ct);
+
+ foreach (var env in environments)
+ {
+ db.FeatureStates.Add(new FeatureState
+ {
+ FeatureId = feature.Id,
+ EnvironmentId = env.Id,
+ Enabled = feature.DefaultEnabled,
+ Value = initialValue,
+ CreatedAt = DateTimeOffset.UtcNow,
+ UpdatedAt = DateTimeOffset.UtcNow
+ });
+ }
+
+ if (environments.Count > 0)
+ await db.SaveChangesAsync(ct);
+
+ var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == projectId, ct);
+ if (project is not null)
+ await auditService.LogAsync("Feature", feature.Id.ToString(), "created",
+ project.OrganizationId, projectId, ct: ct);
+
+ return feature;
+ }
+
+ public async Task UpdateAsync(
+ int id, string name, string? description, CancellationToken ct = default)
+ {
+ var feature = await db.Features.FirstOrDefaultAsync(f => f.Id == id, ct)
+ ?? throw new KeyNotFoundException($"Feature {id} not found.");
+
+ feature.Name = name;
+ feature.Description = description;
+ await db.SaveChangesAsync(ct);
+
+ var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == feature.ProjectId, ct);
+ if (project is not null)
+ await auditService.LogAsync("Feature", feature.Id.ToString(), "updated",
+ project.OrganizationId, feature.ProjectId, ct: ct);
+
+ return feature;
+ }
+
+ public async Task DeleteAsync(int id, CancellationToken ct = default)
+ {
+ var feature = await db.Features.FirstOrDefaultAsync(f => f.Id == id, ct);
+ if (feature is null) return;
+
+ db.Features.Remove(feature);
+ await db.SaveChangesAsync(ct);
+ }
+}
diff --git a/src/api/MicCheck.Api/Features/FeatureStateResponse.cs b/src/api/MicCheck.Api/Features/FeatureStateResponse.cs
new file mode 100644
index 0000000..3625791
--- /dev/null
+++ b/src/api/MicCheck.Api/Features/FeatureStateResponse.cs
@@ -0,0 +1,25 @@
+namespace MicCheck.Api.Features;
+
+public record FeatureStateResponse(
+ int Id,
+ int FeatureId,
+ int EnvironmentId,
+ int? IdentityId,
+ int? FeatureSegmentId,
+ bool Enabled,
+ string? Value,
+ DateTimeOffset CreatedAt,
+ DateTimeOffset UpdatedAt
+)
+{
+ public static FeatureStateResponse From(FeatureState state) => new(
+ state.Id,
+ state.FeatureId,
+ state.EnvironmentId,
+ state.IdentityId,
+ state.FeatureSegmentId,
+ state.Enabled,
+ state.Value,
+ state.CreatedAt,
+ state.UpdatedAt);
+}
diff --git a/src/api/MicCheck.Api/Features/FeatureStateService.cs b/src/api/MicCheck.Api/Features/FeatureStateService.cs
new file mode 100644
index 0000000..5f40b7b
--- /dev/null
+++ b/src/api/MicCheck.Api/Features/FeatureStateService.cs
@@ -0,0 +1,46 @@
+using MicCheck.Api.Data;
+using Microsoft.EntityFrameworkCore;
+
+namespace MicCheck.Api.Features;
+
+public class FeatureStateService(MicCheckDbContext db)
+{
+ public async Task> ListByEnvironmentAsync(int environmentId, CancellationToken ct = default)
+ {
+ return await db.FeatureStates
+ .Where(fs => fs.EnvironmentId == environmentId && fs.IdentityId == null && fs.FeatureSegmentId == null)
+ .ToListAsync(ct);
+ }
+
+ public async Task FindByIdAsync(int id, int environmentId, CancellationToken ct = default)
+ {
+ return await db.FeatureStates
+ .FirstOrDefaultAsync(fs => fs.Id == id && fs.EnvironmentId == environmentId, ct);
+ }
+
+ public async Task UpdateAsync(int id, bool enabled, string? value, CancellationToken ct = default)
+ {
+ var state = await db.FeatureStates.FirstOrDefaultAsync(fs => fs.Id == id, ct)
+ ?? throw new KeyNotFoundException($"FeatureState {id} not found.");
+
+ state.Enabled = enabled;
+ state.Value = value;
+ state.UpdatedAt = DateTimeOffset.UtcNow;
+ state.Version++;
+ await db.SaveChangesAsync(ct);
+ return state;
+ }
+
+ public async Task PatchAsync(int id, bool? enabled, string? value, CancellationToken ct = default)
+ {
+ var state = await db.FeatureStates.FirstOrDefaultAsync(fs => fs.Id == id, ct)
+ ?? throw new KeyNotFoundException($"FeatureState {id} not found.");
+
+ if (enabled.HasValue) state.Enabled = enabled.Value;
+ if (value is not null) state.Value = value;
+ state.UpdatedAt = DateTimeOffset.UtcNow;
+ state.Version++;
+ await db.SaveChangesAsync(ct);
+ return state;
+ }
+}
diff --git a/src/api/MicCheck.Api/Features/FeaturesController.cs b/src/api/MicCheck.Api/Features/FeaturesController.cs
new file mode 100644
index 0000000..bfb6a51
--- /dev/null
+++ b/src/api/MicCheck.Api/Features/FeaturesController.cs
@@ -0,0 +1,88 @@
+using MicCheck.Api.Authorization;
+using MicCheck.Api.Common;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.RateLimiting;
+
+namespace MicCheck.Api.Features;
+
+[ApiController]
+[Route("api/v1/projects/{projectId}/features")]
+[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
+[EnableRateLimiting("AdminApi")]
+public class FeaturesController(FeatureService featureService) : ControllerBase
+{
+ [HttpGet]
+ public async Task>> List(
+ int projectId,
+ [FromQuery] int page = 1,
+ [FromQuery] int pageSize = 20,
+ CancellationToken ct = default)
+ {
+ pageSize = Math.Clamp(pageSize, 1, 100);
+ var all = await featureService.ListByProjectAsync(projectId, ct);
+ var paged = all.Skip((page - 1) * pageSize).Take(pageSize).Select(FeatureResponse.From).ToList();
+ return Ok(new PaginatedResponse(all.Count, null, null, paged));
+ }
+
+ [HttpPost]
+ public async Task> Create(
+ int projectId, CreateFeatureRequest request, CancellationToken ct)
+ {
+ try
+ {
+ var feature = await featureService.CreateAsync(
+ projectId, request.Name, request.Type, request.InitialValue, request.Description, ct);
+
+ return CreatedAtAction(nameof(GetById), new { projectId, id = feature.Id }, FeatureResponse.From(feature));
+ }
+ catch (DomainException ex)
+ {
+ return BadRequest(new { error = ex.Message });
+ }
+ }
+
+ [HttpGet("{id}")]
+ public async Task> GetById(int projectId, int id, CancellationToken ct)
+ {
+ var feature = await featureService.FindByIdAsync(id, ct);
+ if (feature is null || feature.ProjectId != projectId) return NotFound();
+ return Ok(FeatureResponse.From(feature));
+ }
+
+ [HttpPut("{id}")]
+ public async Task> Update(
+ int projectId, int id, UpdateFeatureRequest request, CancellationToken ct)
+ {
+ var feature = await featureService.FindByIdAsync(id, ct);
+ if (feature is null || feature.ProjectId != projectId) return NotFound();
+
+ var updated = await featureService.UpdateAsync(id, request.Name, request.Description, ct);
+ return Ok(FeatureResponse.From(updated));
+ }
+
+ [HttpPatch("{id}")]
+ public async Task> Patch(
+ int projectId, int id, PatchFeatureRequest request, CancellationToken ct)
+ {
+ var feature = await featureService.FindByIdAsync(id, ct);
+ if (feature is null || feature.ProjectId != projectId) return NotFound();
+
+ if (request.Name is not null) feature.Name = request.Name;
+ if (request.Description is not null) feature.Description = request.Description;
+ if (request.DefaultEnabled.HasValue) feature.DefaultEnabled = request.DefaultEnabled.Value;
+
+ var updated = await featureService.UpdateAsync(id, feature.Name, feature.Description, ct);
+ return Ok(FeatureResponse.From(updated));
+ }
+
+ [HttpDelete("{id}")]
+ public async Task Delete(int projectId, int id, CancellationToken ct)
+ {
+ var feature = await featureService.FindByIdAsync(id, ct);
+ if (feature is null || feature.ProjectId != projectId) return NotFound();
+
+ await featureService.DeleteAsync(id, ct);
+ return NoContent();
+ }
+}
diff --git a/src/api/MicCheck.Api/Features/PatchFeatureRequest.cs b/src/api/MicCheck.Api/Features/PatchFeatureRequest.cs
new file mode 100644
index 0000000..b16b453
--- /dev/null
+++ b/src/api/MicCheck.Api/Features/PatchFeatureRequest.cs
@@ -0,0 +1,7 @@
+namespace MicCheck.Api.Features;
+
+public record PatchFeatureRequest(
+ string? Name,
+ string? Description,
+ bool? DefaultEnabled
+);
diff --git a/src/api/MicCheck.Api/Features/TagResponse.cs b/src/api/MicCheck.Api/Features/TagResponse.cs
new file mode 100644
index 0000000..8bb7449
--- /dev/null
+++ b/src/api/MicCheck.Api/Features/TagResponse.cs
@@ -0,0 +1,6 @@
+namespace MicCheck.Api.Features;
+
+public record TagResponse(int Id, string Label, string Color, int ProjectId)
+{
+ public static TagResponse From(Tag tag) => new(tag.Id, tag.Label, tag.Color, tag.ProjectId);
+}
diff --git a/src/api/MicCheck.Api/Features/TagService.cs b/src/api/MicCheck.Api/Features/TagService.cs
new file mode 100644
index 0000000..a2713ef
--- /dev/null
+++ b/src/api/MicCheck.Api/Features/TagService.cs
@@ -0,0 +1,33 @@
+using MicCheck.Api.Data;
+using Microsoft.EntityFrameworkCore;
+
+namespace MicCheck.Api.Features;
+
+public class TagService(MicCheckDbContext db)
+{
+ public async Task> ListByProjectAsync(int projectId, CancellationToken ct = default)
+ {
+ return await db.Tags.Where(t => t.ProjectId == projectId).ToListAsync(ct);
+ }
+
+ public async Task CreateAsync(int projectId, string label, string color, CancellationToken ct = default)
+ {
+ var tag = new Tag { Label = label, Color = color, ProjectId = projectId };
+ db.Tags.Add(tag);
+ await db.SaveChangesAsync(ct);
+ return tag;
+ }
+
+ public async Task FindByIdAsync(int id, CancellationToken ct = default)
+ {
+ return await db.Tags.FirstOrDefaultAsync(t => t.Id == id, ct);
+ }
+
+ public async Task DeleteAsync(int id, CancellationToken ct = default)
+ {
+ var tag = await db.Tags.FirstOrDefaultAsync(t => t.Id == id, ct);
+ if (tag is null) return;
+ db.Tags.Remove(tag);
+ await db.SaveChangesAsync(ct);
+ }
+}
diff --git a/src/api/MicCheck.Api/Features/TagsController.cs b/src/api/MicCheck.Api/Features/TagsController.cs
new file mode 100644
index 0000000..b41cc62
--- /dev/null
+++ b/src/api/MicCheck.Api/Features/TagsController.cs
@@ -0,0 +1,37 @@
+using MicCheck.Api.Authorization;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.RateLimiting;
+
+namespace MicCheck.Api.Features;
+
+[ApiController]
+[Route("api/v1/projects/{projectId}/tags")]
+[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
+[EnableRateLimiting("AdminApi")]
+public class TagsController(TagService tagService) : ControllerBase
+{
+ [HttpGet]
+ public async Task>> List(int projectId, CancellationToken ct)
+ {
+ var tags = await tagService.ListByProjectAsync(projectId, ct);
+ return Ok(tags.Select(TagResponse.From).ToList());
+ }
+
+ [HttpPost]
+ public async Task> Create(int projectId, CreateTagRequest request, CancellationToken ct)
+ {
+ var tag = await tagService.CreateAsync(projectId, request.Label, request.Color, ct);
+ return CreatedAtAction(nameof(List), new { projectId }, TagResponse.From(tag));
+ }
+
+ [HttpDelete("{id}")]
+ public async Task Delete(int projectId, int id, CancellationToken ct)
+ {
+ var tag = await tagService.FindByIdAsync(id, ct);
+ if (tag is null || tag.ProjectId != projectId) return NotFound();
+
+ await tagService.DeleteAsync(id, ct);
+ return NoContent();
+ }
+}
diff --git a/src/api/MicCheck.Api/Features/UpdateFeatureRequest.cs b/src/api/MicCheck.Api/Features/UpdateFeatureRequest.cs
new file mode 100644
index 0000000..451733d
--- /dev/null
+++ b/src/api/MicCheck.Api/Features/UpdateFeatureRequest.cs
@@ -0,0 +1,20 @@
+using FluentValidation;
+
+namespace MicCheck.Api.Features;
+
+public record UpdateFeatureRequest(
+ string Name,
+ string? Description
+);
+
+public class UpdateFeatureRequestValidator : AbstractValidator
+{
+ public UpdateFeatureRequestValidator()
+ {
+ RuleFor(x => x.Name)
+ .NotEmpty()
+ .MaximumLength(150)
+ .Matches("^[a-zA-Z0-9_-]+$")
+ .WithMessage("Name may only contain letters, digits, underscores, and hyphens.");
+ }
+}
diff --git a/src/api/MicCheck.Api/Features/UpdateFeatureStateRequest.cs b/src/api/MicCheck.Api/Features/UpdateFeatureStateRequest.cs
new file mode 100644
index 0000000..6670d7d
--- /dev/null
+++ b/src/api/MicCheck.Api/Features/UpdateFeatureStateRequest.cs
@@ -0,0 +1,5 @@
+namespace MicCheck.Api.Features;
+
+public record UpdateFeatureStateRequest(bool Enabled, string? Value);
+
+public record PatchFeatureStateRequest(bool? Enabled, string? Value);
diff --git a/src/api/MicCheck.Api/Identities/.gitkeep b/src/api/MicCheck.Api/Identities/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/src/api/MicCheck.Api/Identities/AdminIdentityResponse.cs b/src/api/MicCheck.Api/Identities/AdminIdentityResponse.cs
new file mode 100644
index 0000000..b4ea0ec
--- /dev/null
+++ b/src/api/MicCheck.Api/Identities/AdminIdentityResponse.cs
@@ -0,0 +1,17 @@
+namespace MicCheck.Api.Identities;
+
+public record AdminIdentityResponse(
+ int Id,
+ string Identifier,
+ int EnvironmentId,
+ DateTimeOffset CreatedAt,
+ IReadOnlyList Traits
+)
+{
+ public static AdminIdentityResponse From(Identity identity) => new(
+ identity.Id,
+ identity.Identifier,
+ identity.EnvironmentId,
+ identity.CreatedAt,
+ identity.Traits.Select(t => new TraitResponse(t.Key, t.Value)).ToList());
+}
diff --git a/src/api/MicCheck.Api/Identities/AdminIdentityService.cs b/src/api/MicCheck.Api/Identities/AdminIdentityService.cs
new file mode 100644
index 0000000..7c44774
--- /dev/null
+++ b/src/api/MicCheck.Api/Identities/AdminIdentityService.cs
@@ -0,0 +1,93 @@
+using MicCheck.Api.Data;
+using MicCheck.Api.Features;
+using Microsoft.EntityFrameworkCore;
+
+namespace MicCheck.Api.Identities;
+
+public class AdminIdentityService(MicCheckDbContext db)
+{
+ public async Task<(int Total, IReadOnlyList Items)> ListAsync(
+ int environmentId, int page, int pageSize, CancellationToken ct = default)
+ {
+ var query = db.Identities
+ .Include(i => i.Traits)
+ .Where(i => i.EnvironmentId == environmentId);
+
+ var total = await query.CountAsync(ct);
+ var items = await query
+ .OrderBy(i => i.Id)
+ .Skip((page - 1) * pageSize)
+ .Take(pageSize)
+ .ToListAsync(ct);
+
+ return (total, items);
+ }
+
+ public async Task FindByIdAsync(int id, int environmentId, CancellationToken ct = default)
+ {
+ return await db.Identities
+ .Include(i => i.Traits)
+ .FirstOrDefaultAsync(i => i.Id == id && i.EnvironmentId == environmentId, ct);
+ }
+
+ public async Task DeleteAsync(int id, CancellationToken ct = default)
+ {
+ var identity = await db.Identities.FirstOrDefaultAsync(i => i.Id == id, ct);
+ if (identity is null) return;
+
+ db.Identities.Remove(identity);
+ await db.SaveChangesAsync(ct);
+ }
+
+ public async Task> GetFeatureStatesAsync(
+ int identityId, int environmentId, CancellationToken ct = default)
+ {
+ return await db.FeatureStates
+ .Where(fs => fs.IdentityId == identityId && fs.EnvironmentId == environmentId)
+ .ToListAsync(ct);
+ }
+
+ public async Task SetFeatureStateAsync(
+ int identityId, int environmentId, int featureId, bool enabled, string? value, CancellationToken ct = default)
+ {
+ var state = await db.FeatureStates
+ .FirstOrDefaultAsync(fs => fs.IdentityId == identityId && fs.EnvironmentId == environmentId && fs.FeatureId == featureId, ct);
+
+ if (state is not null)
+ {
+ state.Enabled = enabled;
+ state.Value = value;
+ state.UpdatedAt = DateTimeOffset.UtcNow;
+ state.Version++;
+ }
+ else
+ {
+ state = new FeatureState
+ {
+ FeatureId = featureId,
+ EnvironmentId = environmentId,
+ IdentityId = identityId,
+ Enabled = enabled,
+ Value = value,
+ CreatedAt = DateTimeOffset.UtcNow,
+ UpdatedAt = DateTimeOffset.UtcNow
+ };
+ db.FeatureStates.Add(state);
+ }
+
+ await db.SaveChangesAsync(ct);
+ return state;
+ }
+
+ public async Task DeleteFeatureStateAsync(
+ int identityId, int environmentId, int featureId, CancellationToken ct = default)
+ {
+ var state = await db.FeatureStates
+ .FirstOrDefaultAsync(fs => fs.IdentityId == identityId && fs.EnvironmentId == environmentId && fs.FeatureId == featureId, ct);
+
+ if (state is null) return;
+
+ db.FeatureStates.Remove(state);
+ await db.SaveChangesAsync(ct);
+ }
+}
diff --git a/src/api/MicCheck.Api/MicCheck.Api.csproj b/src/api/MicCheck.Api/MicCheck.Api.csproj
index 47cada6..28399d3 100644
--- a/src/api/MicCheck.Api/MicCheck.Api.csproj
+++ b/src/api/MicCheck.Api/MicCheck.Api.csproj
@@ -23,8 +23,4 @@
-
-
-
-
diff --git a/src/api/MicCheck.Api/Organizations/CreateOrganizationRequest.cs b/src/api/MicCheck.Api/Organizations/CreateOrganizationRequest.cs
new file mode 100644
index 0000000..32f755b
--- /dev/null
+++ b/src/api/MicCheck.Api/Organizations/CreateOrganizationRequest.cs
@@ -0,0 +1,13 @@
+using FluentValidation;
+
+namespace MicCheck.Api.Organizations;
+
+public record CreateOrganizationRequest(string Name);
+
+public class CreateOrganizationRequestValidator : AbstractValidator
+{
+ public CreateOrganizationRequestValidator()
+ {
+ RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
+ }
+}
diff --git a/src/api/MicCheck.Api/Organizations/InviteUserRequest.cs b/src/api/MicCheck.Api/Organizations/InviteUserRequest.cs
new file mode 100644
index 0000000..a0ca37e
--- /dev/null
+++ b/src/api/MicCheck.Api/Organizations/InviteUserRequest.cs
@@ -0,0 +1,15 @@
+using FluentValidation;
+
+namespace MicCheck.Api.Organizations;
+
+public record InviteUserRequest(int UserId, string Role);
+
+public class InviteUserRequestValidator : AbstractValidator
+{
+ public InviteUserRequestValidator()
+ {
+ RuleFor(x => x.UserId).GreaterThan(0);
+ RuleFor(x => x.Role).NotEmpty().Must(r => Enum.TryParse(r, true, out _))
+ .WithMessage("Role must be 'User' or 'Admin'.");
+ }
+}
diff --git a/src/api/MicCheck.Api/Organizations/OrganizationResponse.cs b/src/api/MicCheck.Api/Organizations/OrganizationResponse.cs
new file mode 100644
index 0000000..fd8df99
--- /dev/null
+++ b/src/api/MicCheck.Api/Organizations/OrganizationResponse.cs
@@ -0,0 +1,20 @@
+namespace MicCheck.Api.Organizations;
+
+public record OrganizationResponse(
+ int Id,
+ string Name,
+ DateTimeOffset CreatedAt
+)
+{
+ public static OrganizationResponse From(Organization org) => new(
+ org.Id, org.Name, org.CreatedAt);
+}
+
+public record OrganizationMemberResponse(
+ int UserId,
+ string Role
+)
+{
+ public static OrganizationMemberResponse From(OrganizationUser member) => new(
+ member.UserId, member.Role.ToString());
+}
diff --git a/src/api/MicCheck.Api/Organizations/OrganizationService.cs b/src/api/MicCheck.Api/Organizations/OrganizationService.cs
new file mode 100644
index 0000000..132847c
--- /dev/null
+++ b/src/api/MicCheck.Api/Organizations/OrganizationService.cs
@@ -0,0 +1,102 @@
+using MicCheck.Api.Audit;
+using MicCheck.Api.Data;
+using Microsoft.EntityFrameworkCore;
+
+namespace MicCheck.Api.Organizations;
+
+public class OrganizationService(MicCheckDbContext db, AuditService auditService)
+{
+ public async Task> ListForUserAsync(int userId, CancellationToken ct = default)
+ {
+ return await db.Organizations
+ .Where(o => o.Members.Any(m => m.UserId == userId))
+ .ToListAsync(ct);
+ }
+
+ public async Task FindByIdAsync(int id, CancellationToken ct = default)
+ {
+ return await db.Organizations.FirstOrDefaultAsync(o => o.Id == id, ct);
+ }
+
+ public async Task CreateAsync(string name, int creatorUserId, CancellationToken ct = default)
+ {
+ var org = new Organization
+ {
+ Name = name,
+ CreatedAt = DateTimeOffset.UtcNow
+ };
+ org.Members.Add(new OrganizationUser
+ {
+ UserId = creatorUserId,
+ Role = OrganizationRole.Admin
+ });
+ db.Organizations.Add(org);
+ await db.SaveChangesAsync(ct);
+
+ await auditService.LogAsync("Organization", org.Id.ToString(), "created", org.Id, ct: ct);
+
+ return org;
+ }
+
+ public async Task UpdateAsync(int id, string name, CancellationToken ct = default)
+ {
+ var org = await db.Organizations.FirstOrDefaultAsync(o => o.Id == id, ct)
+ ?? throw new KeyNotFoundException($"Organization {id} not found.");
+
+ org.Name = name;
+ await db.SaveChangesAsync(ct);
+
+ await auditService.LogAsync("Organization", org.Id.ToString(), "updated", org.Id, ct: ct);
+
+ return org;
+ }
+
+ public async Task DeleteAsync(int id, CancellationToken ct = default)
+ {
+ var org = await db.Organizations.FirstOrDefaultAsync(o => o.Id == id, ct);
+ if (org is null) return;
+
+ db.Organizations.Remove(org);
+ await db.SaveChangesAsync(ct);
+ }
+
+ public async Task> ListMembersAsync(int organizationId, CancellationToken ct = default)
+ {
+ return await db.OrganizationUsers
+ .Where(m => m.OrganizationId == organizationId)
+ .ToListAsync(ct);
+ }
+
+ public async Task InviteUserAsync(int organizationId, int userId, OrganizationRole role, CancellationToken ct = default)
+ {
+ var existing = await db.OrganizationUsers
+ .FirstOrDefaultAsync(m => m.OrganizationId == organizationId && m.UserId == userId, ct);
+
+ if (existing is not null)
+ {
+ existing.Role = role;
+ }
+ else
+ {
+ db.OrganizationUsers.Add(new OrganizationUser
+ {
+ OrganizationId = organizationId,
+ UserId = userId,
+ Role = role
+ });
+ }
+
+ await db.SaveChangesAsync(ct);
+ }
+
+ public async Task RemoveMemberAsync(int organizationId, int userId, CancellationToken ct = default)
+ {
+ var member = await db.OrganizationUsers
+ .FirstOrDefaultAsync(m => m.OrganizationId == organizationId && m.UserId == userId, ct);
+
+ if (member is null) return;
+
+ db.OrganizationUsers.Remove(member);
+ await db.SaveChangesAsync(ct);
+ }
+}
diff --git a/src/api/MicCheck.Api/Organizations/OrganizationsController.cs b/src/api/MicCheck.Api/Organizations/OrganizationsController.cs
new file mode 100644
index 0000000..401b8e1
--- /dev/null
+++ b/src/api/MicCheck.Api/Organizations/OrganizationsController.cs
@@ -0,0 +1,106 @@
+using MicCheck.Api.Authorization;
+using MicCheck.Api.Common;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.RateLimiting;
+
+namespace MicCheck.Api.Organizations;
+
+[ApiController]
+[Route("api/v1/organisations")]
+[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
+[EnableRateLimiting("AdminApi")]
+public class OrganizationsController(OrganizationService organizationService) : ControllerBase
+{
+ [HttpGet]
+ public async Task>> List(
+ [FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken ct = default)
+ {
+ pageSize = Math.Clamp(pageSize, 1, 100);
+ var userId = GetCurrentUserId();
+ if (userId is null) return Unauthorized();
+
+ var all = await organizationService.ListForUserAsync(userId.Value, ct);
+ var paged = all.Skip((page - 1) * pageSize).Take(pageSize).Select(OrganizationResponse.From).ToList();
+
+ return Ok(new PaginatedResponse(all.Count, null, null, paged));
+ }
+
+ [HttpPost]
+ public async Task> Create(
+ CreateOrganizationRequest request, CancellationToken ct)
+ {
+ var userId = GetCurrentUserId();
+ if (userId is null) return Unauthorized();
+
+ var org = await organizationService.CreateAsync(request.Name, userId.Value, ct);
+ return CreatedAtAction(nameof(GetById), new { id = org.Id }, OrganizationResponse.From(org));
+ }
+
+ [HttpGet("{id}")]
+ public async Task> GetById(int id, CancellationToken ct)
+ {
+ var org = await organizationService.FindByIdAsync(id, ct);
+ if (org is null) return NotFound();
+ return Ok(OrganizationResponse.From(org));
+ }
+
+ [HttpPut("{id}")]
+ public async Task> Update(
+ int id, UpdateOrganizationRequest request, CancellationToken ct)
+ {
+ var org = await organizationService.FindByIdAsync(id, ct);
+ if (org is null) return NotFound();
+
+ var updated = await organizationService.UpdateAsync(id, request.Name, ct);
+ return Ok(OrganizationResponse.From(updated));
+ }
+
+ [HttpDelete("{id}")]
+ public async Task Delete(int id, CancellationToken ct)
+ {
+ var org = await organizationService.FindByIdAsync(id, ct);
+ if (org is null) return NotFound();
+
+ await organizationService.DeleteAsync(id, ct);
+ return NoContent();
+ }
+
+ [HttpGet("{id}/users")]
+ public async Task>> ListUsers(
+ int id, CancellationToken ct)
+ {
+ var org = await organizationService.FindByIdAsync(id, ct);
+ if (org is null) return NotFound();
+
+ var members = await organizationService.ListMembersAsync(id, ct);
+ return Ok(members.Select(OrganizationMemberResponse.From).ToList());
+ }
+
+ [HttpPost("{id}/users/invite")]
+ public async Task InviteUser(int id, InviteUserRequest request, CancellationToken ct)
+ {
+ var org = await organizationService.FindByIdAsync(id, ct);
+ if (org is null) return NotFound();
+
+ var role = Enum.Parse(request.Role, ignoreCase: true);
+ await organizationService.InviteUserAsync(id, request.UserId, role, ct);
+ return Ok();
+ }
+
+ [HttpDelete("{id}/users/{userId}")]
+ public async Task RemoveUser(int id, int userId, CancellationToken ct)
+ {
+ var org = await organizationService.FindByIdAsync(id, ct);
+ if (org is null) return NotFound();
+
+ await organizationService.RemoveMemberAsync(id, userId, ct);
+ return NoContent();
+ }
+
+ private int? GetCurrentUserId()
+ {
+ var claim = User.FindFirst("UserId")?.Value;
+ return int.TryParse(claim, out var id) ? id : null;
+ }
+}
diff --git a/src/api/MicCheck.Api/Organizations/UpdateOrganizationRequest.cs b/src/api/MicCheck.Api/Organizations/UpdateOrganizationRequest.cs
new file mode 100644
index 0000000..16a951f
--- /dev/null
+++ b/src/api/MicCheck.Api/Organizations/UpdateOrganizationRequest.cs
@@ -0,0 +1,13 @@
+using FluentValidation;
+
+namespace MicCheck.Api.Organizations;
+
+public record UpdateOrganizationRequest(string Name);
+
+public class UpdateOrganizationRequestValidator : AbstractValidator
+{
+ public UpdateOrganizationRequestValidator()
+ {
+ RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
+ }
+}
diff --git a/src/api/MicCheck.Api/Program.cs b/src/api/MicCheck.Api/Program.cs
index de98b10..c21ef6f 100644
--- a/src/api/MicCheck.Api/Program.cs
+++ b/src/api/MicCheck.Api/Program.cs
@@ -3,6 +3,7 @@ using System.Threading.RateLimiting;
using FluentValidation;
using FluentValidation.AspNetCore;
using MicCheck.Api.ApiKeys;
+using MicCheck.Api.Audit;
using MicCheck.Api.Auth;
using MicCheck.Api.Authentication;
using MicCheck.Api.Authorization;
@@ -10,12 +11,16 @@ using MicCheck.Api.Data;
using MicCheck.Api.Environments;
using MicCheck.Api.Features;
using MicCheck.Api.Identities;
+using MicCheck.Api.Organizations;
+using MicCheck.Api.Projects;
using MicCheck.Api.Segments;
using MicCheck.Api.Users;
+using MicCheck.Api.Webhooks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
@@ -36,7 +41,10 @@ try
.WriteTo.Console());
builder.Services.AddOpenApi();
- builder.Services.AddControllers();
+ builder.Services.AddControllers()
+ .AddJsonOptions(options =>
+ options.JsonSerializerOptions.Converters.Add(
+ new System.Text.Json.Serialization.JsonStringEnumConverter()));
builder.Services.AddAuthentication()
.AddScheme(
@@ -86,6 +94,19 @@ try
builder.Services.AddFluentValidationAutoValidation();
builder.Services.AddValidatorsFromAssemblyContaining();
+ builder.Services.Configure(options =>
+ {
+ options.InvalidModelStateResponseFactory = context =>
+ {
+ var errors = context.ModelState
+ .Where(e => e.Value?.Errors.Count > 0)
+ .ToDictionary(
+ kvp => kvp.Key,
+ kvp => kvp.Value!.Errors.Select(e => e.ErrorMessage).ToArray());
+ return new UnprocessableEntityObjectResult(new { errors });
+ };
+ });
+
builder.Services.AddMemoryCache();
builder.Services.AddScoped();
@@ -99,6 +120,17 @@ try
builder.Services.AddScoped();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
var connectionString = System.Environment.GetEnvironmentVariable("DATABASE_URL") is { } databaseUrl
? DatabaseUrlParser.ToNpgsqlConnectionString(databaseUrl)
@@ -138,5 +170,3 @@ finally
{
Log.CloseAndFlush();
}
-
-public partial class Program { }
diff --git a/src/api/MicCheck.Api/Projects/CreateProjectRequest.cs b/src/api/MicCheck.Api/Projects/CreateProjectRequest.cs
new file mode 100644
index 0000000..155c34f
--- /dev/null
+++ b/src/api/MicCheck.Api/Projects/CreateProjectRequest.cs
@@ -0,0 +1,14 @@
+using FluentValidation;
+
+namespace MicCheck.Api.Projects;
+
+public record CreateProjectRequest(string Name, int OrganizationId);
+
+public class CreateProjectRequestValidator : AbstractValidator
+{
+ public CreateProjectRequestValidator()
+ {
+ RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
+ RuleFor(x => x.OrganizationId).GreaterThan(0);
+ }
+}
diff --git a/src/api/MicCheck.Api/Projects/ProjectResponse.cs b/src/api/MicCheck.Api/Projects/ProjectResponse.cs
new file mode 100644
index 0000000..7740dee
--- /dev/null
+++ b/src/api/MicCheck.Api/Projects/ProjectResponse.cs
@@ -0,0 +1,27 @@
+using MicCheck.Api.Authorization;
+
+namespace MicCheck.Api.Projects;
+
+public record ProjectResponse(
+ int Id,
+ string Name,
+ int OrganizationId,
+ bool HideDisabledFlags,
+ DateTimeOffset CreatedAt
+)
+{
+ public static ProjectResponse From(Project project) => new(
+ project.Id, project.Name, project.OrganizationId, project.HideDisabledFlags, project.CreatedAt);
+}
+
+public record UserPermissionResponse(
+ int UserId,
+ int ProjectId,
+ bool IsAdmin,
+ IReadOnlyList Permissions
+)
+{
+ public static UserPermissionResponse From(UserProjectPermission p) => new(
+ p.UserId, p.ProjectId, p.IsAdmin,
+ p.Permissions.Select(x => x.ToString()).ToList());
+}
diff --git a/src/api/MicCheck.Api/Projects/ProjectService.cs b/src/api/MicCheck.Api/Projects/ProjectService.cs
new file mode 100644
index 0000000..8ef4df0
--- /dev/null
+++ b/src/api/MicCheck.Api/Projects/ProjectService.cs
@@ -0,0 +1,105 @@
+using MicCheck.Api.Audit;
+using MicCheck.Api.Authorization;
+using MicCheck.Api.Data;
+using Microsoft.EntityFrameworkCore;
+
+namespace MicCheck.Api.Projects;
+
+public class ProjectService(MicCheckDbContext db, AuditService auditService)
+{
+ public async Task> ListByOrganizationAsync(int organizationId, CancellationToken ct = default)
+ {
+ return await db.Projects
+ .Where(p => p.OrganizationId == organizationId)
+ .ToListAsync(ct);
+ }
+
+ public async Task FindByIdAsync(int id, CancellationToken ct = default)
+ {
+ return await db.Projects.FirstOrDefaultAsync(p => p.Id == id, ct);
+ }
+
+ public async Task CreateAsync(int organizationId, string name, CancellationToken ct = default)
+ {
+ var project = new Project
+ {
+ Name = name,
+ OrganizationId = organizationId,
+ CreatedAt = DateTimeOffset.UtcNow
+ };
+ db.Projects.Add(project);
+ await db.SaveChangesAsync(ct);
+
+ await auditService.LogAsync("Project", project.Id.ToString(), "created", organizationId, project.Id, ct: ct);
+
+ return project;
+ }
+
+ public async Task UpdateAsync(int id, string name, bool hideDisabledFlags, CancellationToken ct = default)
+ {
+ var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == id, ct)
+ ?? throw new KeyNotFoundException($"Project {id} not found.");
+
+ project.Name = name;
+ project.HideDisabledFlags = hideDisabledFlags;
+ await db.SaveChangesAsync(ct);
+
+ await auditService.LogAsync("Project", project.Id.ToString(), "updated", project.OrganizationId, project.Id, ct: ct);
+
+ return project;
+ }
+
+ public async Task DeleteAsync(int id, CancellationToken ct = default)
+ {
+ var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == id, ct);
+ if (project is null) return;
+
+ db.Projects.Remove(project);
+ await db.SaveChangesAsync(ct);
+ }
+
+ public async Task> ListUserPermissionsAsync(int projectId, CancellationToken ct = default)
+ {
+ return await db.UserProjectPermissions
+ .Where(p => p.ProjectId == projectId)
+ .ToListAsync(ct);
+ }
+
+ public async Task SetUserPermissionsAsync(
+ int projectId, int userId, bool isAdmin, List permissions, CancellationToken ct = default)
+ {
+ var existing = await db.UserProjectPermissions
+ .FirstOrDefaultAsync(p => p.ProjectId == projectId && p.UserId == userId, ct);
+
+ if (existing is not null)
+ {
+ existing.IsAdmin = isAdmin;
+ existing.Permissions = permissions;
+ }
+ else
+ {
+ existing = new UserProjectPermission
+ {
+ ProjectId = projectId,
+ UserId = userId,
+ IsAdmin = isAdmin,
+ Permissions = permissions
+ };
+ db.UserProjectPermissions.Add(existing);
+ }
+
+ await db.SaveChangesAsync(ct);
+ return existing;
+ }
+
+ public async Task RemoveUserPermissionsAsync(int projectId, int userId, CancellationToken ct = default)
+ {
+ var perm = await db.UserProjectPermissions
+ .FirstOrDefaultAsync(p => p.ProjectId == projectId && p.UserId == userId, ct);
+
+ if (perm is null) return;
+
+ db.UserProjectPermissions.Remove(perm);
+ await db.SaveChangesAsync(ct);
+ }
+}
diff --git a/src/api/MicCheck.Api/Projects/ProjectsController.cs b/src/api/MicCheck.Api/Projects/ProjectsController.cs
new file mode 100644
index 0000000..3934b2b
--- /dev/null
+++ b/src/api/MicCheck.Api/Projects/ProjectsController.cs
@@ -0,0 +1,113 @@
+using MicCheck.Api.Authorization;
+using MicCheck.Api.Common;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.RateLimiting;
+
+namespace MicCheck.Api.Projects;
+
+[ApiController]
+[Route("api/v1/projects")]
+[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
+[EnableRateLimiting("AdminApi")]
+public class ProjectsController(ProjectService projectService) : ControllerBase
+{
+ [HttpGet]
+ public async Task>> List(
+ [FromQuery] int organizationId,
+ [FromQuery] int page = 1,
+ [FromQuery] int pageSize = 20,
+ CancellationToken ct = default)
+ {
+ pageSize = Math.Clamp(pageSize, 1, 100);
+ var all = await projectService.ListByOrganizationAsync(organizationId, ct);
+ var paged = all.Skip((page - 1) * pageSize).Take(pageSize).Select(ProjectResponse.From).ToList();
+ return Ok(new PaginatedResponse(all.Count, null, null, paged));
+ }
+
+ [HttpPost]
+ public async Task> Create(CreateProjectRequest request, CancellationToken ct)
+ {
+ var project = await projectService.CreateAsync(request.OrganizationId, request.Name, ct);
+ return CreatedAtAction(nameof(GetById), new { id = project.Id }, ProjectResponse.From(project));
+ }
+
+ [HttpGet("{id}")]
+ public async Task> GetById(int id, CancellationToken ct)
+ {
+ var project = await projectService.FindByIdAsync(id, ct);
+ if (project is null) return NotFound();
+ return Ok(ProjectResponse.From(project));
+ }
+
+ [HttpPut("{id}")]
+ public async Task> Update(int id, UpdateProjectRequest request, CancellationToken ct)
+ {
+ var project = await projectService.FindByIdAsync(id, ct);
+ if (project is null) return NotFound();
+
+ var updated = await projectService.UpdateAsync(id, request.Name, request.HideDisabledFlags, ct);
+ return Ok(ProjectResponse.From(updated));
+ }
+
+ [HttpDelete("{id}")]
+ public async Task Delete(int id, CancellationToken ct)
+ {
+ var project = await projectService.FindByIdAsync(id, ct);
+ if (project is null) return NotFound();
+
+ await projectService.DeleteAsync(id, ct);
+ return NoContent();
+ }
+
+ [HttpGet("{id}/user-permissions")]
+ public async Task>> ListUserPermissions(
+ int id, CancellationToken ct)
+ {
+ var project = await projectService.FindByIdAsync(id, ct);
+ if (project is null) return NotFound();
+
+ var perms = await projectService.ListUserPermissionsAsync(id, ct);
+ return Ok(perms.Select(UserPermissionResponse.From).ToList());
+ }
+
+ [HttpPost("{id}/user-permissions")]
+ public async Task> CreateUserPermissions(
+ int id, SetUserPermissionsRequest request, CancellationToken ct)
+ {
+ var project = await projectService.FindByIdAsync(id, ct);
+ if (project is null) return NotFound();
+
+ var permissions = request.Permissions
+ .Select(p => Enum.Parse(p, ignoreCase: true))
+ .ToList();
+
+ var perm = await projectService.SetUserPermissionsAsync(id, request.UserId, request.IsAdmin, permissions, ct);
+ return Ok(UserPermissionResponse.From(perm));
+ }
+
+ [HttpPut("{id}/user-permissions/{userId}")]
+ public async Task> UpdateUserPermissions(
+ int id, int userId, SetUserPermissionsRequest request, CancellationToken ct)
+ {
+ var project = await projectService.FindByIdAsync(id, ct);
+ if (project is null) return NotFound();
+
+ var permissions = request.Permissions
+ .Select(p => Enum.Parse(p, ignoreCase: true))
+ .ToList();
+
+ var perm = await projectService.SetUserPermissionsAsync(id, userId, request.IsAdmin, permissions, ct);
+ return Ok(UserPermissionResponse.From(perm));
+ }
+
+ [HttpDelete("{id}/user-permissions/{userId}")]
+ public async Task DeleteUserPermissions(int id, int userId, CancellationToken ct)
+ {
+ var project = await projectService.FindByIdAsync(id, ct);
+ if (project is null) return NotFound();
+
+ await projectService.RemoveUserPermissionsAsync(id, userId, ct);
+ return NoContent();
+ }
+}
diff --git a/src/api/MicCheck.Api/Projects/SetUserPermissionsRequest.cs b/src/api/MicCheck.Api/Projects/SetUserPermissionsRequest.cs
new file mode 100644
index 0000000..4023f00
--- /dev/null
+++ b/src/api/MicCheck.Api/Projects/SetUserPermissionsRequest.cs
@@ -0,0 +1,17 @@
+using FluentValidation;
+using MicCheck.Api.Authorization;
+
+namespace MicCheck.Api.Projects;
+
+public record SetUserPermissionsRequest(int UserId, bool IsAdmin, List Permissions);
+
+public class SetUserPermissionsRequestValidator : AbstractValidator
+{
+ public SetUserPermissionsRequestValidator()
+ {
+ RuleFor(x => x.UserId).GreaterThan(0);
+ RuleForEach(x => x.Permissions)
+ .Must(p => Enum.TryParse(p, true, out _))
+ .WithMessage("Invalid permission value.");
+ }
+}
diff --git a/src/api/MicCheck.Api/Projects/UpdateProjectRequest.cs b/src/api/MicCheck.Api/Projects/UpdateProjectRequest.cs
new file mode 100644
index 0000000..9ce7213
--- /dev/null
+++ b/src/api/MicCheck.Api/Projects/UpdateProjectRequest.cs
@@ -0,0 +1,13 @@
+using FluentValidation;
+
+namespace MicCheck.Api.Projects;
+
+public record UpdateProjectRequest(string Name, bool HideDisabledFlags);
+
+public class UpdateProjectRequestValidator : AbstractValidator
+{
+ public UpdateProjectRequestValidator()
+ {
+ RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
+ }
+}
diff --git a/src/api/MicCheck.Api/Segments/.gitkeep b/src/api/MicCheck.Api/Segments/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/src/api/MicCheck.Api/Segments/CreateSegmentRequest.cs b/src/api/MicCheck.Api/Segments/CreateSegmentRequest.cs
new file mode 100644
index 0000000..6661251
--- /dev/null
+++ b/src/api/MicCheck.Api/Segments/CreateSegmentRequest.cs
@@ -0,0 +1,39 @@
+using FluentValidation;
+
+namespace MicCheck.Api.Segments;
+
+public record CreateSegmentConditionRequest(
+ string Property,
+ string Operator,
+ string Value);
+
+public record CreateSegmentRuleRequest(
+ string Type,
+ IReadOnlyList Conditions,
+ IReadOnlyList? ChildRules = null);
+
+public record CreateSegmentRequest(
+ string Name,
+ IReadOnlyList Rules);
+
+public class CreateSegmentRequestValidator : AbstractValidator
+{
+ public CreateSegmentRequestValidator()
+ {
+ RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
+ RuleFor(x => x.Rules).NotNull();
+ RuleForEach(x => x.Rules).ChildRules(rule =>
+ {
+ rule.RuleFor(r => r.Type)
+ .Must(t => Enum.TryParse(t, true, out _))
+ .WithMessage("Rule type must be 'All', 'Any', or 'None'.");
+ rule.RuleForEach(r => r.Conditions).ChildRules(cond =>
+ {
+ cond.RuleFor(c => c.Property).NotEmpty();
+ cond.RuleFor(c => c.Operator)
+ .Must(o => Enum.TryParse(o, true, out _))
+ .WithMessage("Invalid operator.");
+ });
+ });
+ }
+}
diff --git a/src/api/MicCheck.Api/Segments/SegmentResponse.cs b/src/api/MicCheck.Api/Segments/SegmentResponse.cs
new file mode 100644
index 0000000..029ffe8
--- /dev/null
+++ b/src/api/MicCheck.Api/Segments/SegmentResponse.cs
@@ -0,0 +1,51 @@
+namespace MicCheck.Api.Segments;
+
+public record SegmentResponse(
+ int Id,
+ string Name,
+ int ProjectId,
+ DateTimeOffset CreatedAt,
+ IReadOnlyList Rules
+)
+{
+ public static SegmentResponse From(Segment segment) => new(
+ segment.Id,
+ segment.Name,
+ segment.ProjectId,
+ segment.CreatedAt,
+ segment.Rules
+ .Where(r => r.ParentRuleId == null)
+ .Select(r => SegmentRuleResponse.From(r, segment.Rules.ToList()))
+ .ToList());
+}
+
+public record SegmentRuleResponse(
+ int Id,
+ string Type,
+ IReadOnlyList Conditions,
+ IReadOnlyList ChildRules
+)
+{
+ public static SegmentRuleResponse From(SegmentRule rule, List allRules) => new(
+ rule.Id,
+ rule.Type.ToString(),
+ rule.Conditions.Select(SegmentConditionResponse.From).ToList(),
+ allRules
+ .Where(r => r.ParentRuleId == rule.Id)
+ .Select(r => SegmentRuleResponse.From(r, allRules))
+ .ToList());
+}
+
+public record SegmentConditionResponse(
+ int Id,
+ string Property,
+ string Operator,
+ string Value
+)
+{
+ public static SegmentConditionResponse From(SegmentCondition condition) => new(
+ condition.Id,
+ condition.Property,
+ condition.Operator.ToString(),
+ condition.Value);
+}
diff --git a/src/api/MicCheck.Api/Segments/SegmentService.cs b/src/api/MicCheck.Api/Segments/SegmentService.cs
new file mode 100644
index 0000000..16d2e4d
--- /dev/null
+++ b/src/api/MicCheck.Api/Segments/SegmentService.cs
@@ -0,0 +1,166 @@
+using MicCheck.Api.Audit;
+using MicCheck.Api.Common;
+using MicCheck.Api.Data;
+using Microsoft.EntityFrameworkCore;
+
+namespace MicCheck.Api.Segments;
+
+public record SegmentConditionDefinition(
+ string Property,
+ SegmentConditionOperator Operator,
+ string Value);
+
+public record SegmentRuleDefinition(
+ SegmentRuleType Type,
+ IReadOnlyList Conditions,
+ IReadOnlyList? ChildRules = null);
+
+public class SegmentService(MicCheckDbContext db, AuditService auditService)
+{
+ private const int MaxSegmentsPerProject = 100;
+ private const int MaxConditionsPerSegment = 100;
+
+ public async Task> ListByProjectAsync(int projectId, CancellationToken ct = default)
+ {
+ return await db.Segments
+ .Include(s => s.Rules)
+ .ThenInclude(r => r.Conditions)
+ .Where(s => s.ProjectId == projectId)
+ .ToListAsync(ct);
+ }
+
+ public async Task FindByIdAsync(int id, CancellationToken ct = default)
+ {
+ return await db.Segments
+ .Include(s => s.Rules)
+ .ThenInclude(r => r.Conditions)
+ .FirstOrDefaultAsync(s => s.Id == id, ct);
+ }
+
+ public async Task CreateAsync(
+ int projectId,
+ string name,
+ IReadOnlyList rules,
+ CancellationToken ct = default)
+ {
+ var count = await db.Segments.CountAsync(s => s.ProjectId == projectId, ct);
+ if (count >= MaxSegmentsPerProject)
+ throw new DomainException($"Project has reached the maximum of {MaxSegmentsPerProject} segments.");
+
+ var totalConditions = rules.Sum(r => CountConditions(r));
+ if (totalConditions > MaxConditionsPerSegment)
+ throw new DomainException($"Segment cannot have more than {MaxConditionsPerSegment} conditions.");
+
+ var segment = new Segment
+ {
+ Name = name,
+ ProjectId = projectId,
+ CreatedAt = DateTimeOffset.UtcNow
+ };
+ db.Segments.Add(segment);
+ await db.SaveChangesAsync(ct);
+
+ foreach (var ruleDef in rules)
+ {
+ AddRule(segment.Id, ruleDef, parentRuleId: null);
+ }
+
+ await db.SaveChangesAsync(ct);
+
+ var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == projectId, ct);
+ if (project is not null)
+ await auditService.LogAsync("Segment", segment.Id.ToString(), "created",
+ project.OrganizationId, projectId, ct: ct);
+
+ return segment;
+ }
+
+ public async Task UpdateAsync(
+ int id,
+ string name,
+ IReadOnlyList rules,
+ CancellationToken ct = default)
+ {
+ var segment = await db.Segments
+ .Include(s => s.Rules)
+ .ThenInclude(r => r.Conditions)
+ .FirstOrDefaultAsync(s => s.Id == id, ct)
+ ?? throw new KeyNotFoundException($"Segment {id} not found.");
+
+ var totalConditions = rules.Sum(r => CountConditions(r));
+ if (totalConditions > MaxConditionsPerSegment)
+ throw new DomainException($"Segment cannot have more than {MaxConditionsPerSegment} conditions.");
+
+ var existingRules = await db.SegmentRules
+ .Where(r => r.SegmentId == id)
+ .ToListAsync(ct);
+ var existingConditions = await db.SegmentConditions
+ .Where(c => existingRules.Select(r => r.Id).Contains(c.RuleId))
+ .ToListAsync(ct);
+
+ db.SegmentConditions.RemoveRange(existingConditions);
+ db.SegmentRules.RemoveRange(existingRules);
+ await db.SaveChangesAsync(ct);
+
+ segment.Name = name;
+
+ foreach (var ruleDef in rules)
+ {
+ AddRule(segment.Id, ruleDef, parentRuleId: null);
+ }
+
+ await db.SaveChangesAsync(ct);
+
+ var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == segment.ProjectId, ct);
+ if (project is not null)
+ await auditService.LogAsync("Segment", segment.Id.ToString(), "updated",
+ project.OrganizationId, segment.ProjectId, ct: ct);
+
+ return segment;
+ }
+
+ public async Task DeleteAsync(int id, CancellationToken ct = default)
+ {
+ var segment = await db.Segments.FirstOrDefaultAsync(s => s.Id == id, ct);
+ if (segment is null) return;
+
+ db.Segments.Remove(segment);
+ await db.SaveChangesAsync(ct);
+ }
+
+ private void AddRule(int segmentId, SegmentRuleDefinition def, int? parentRuleId)
+ {
+ var rule = new SegmentRule
+ {
+ SegmentId = segmentId,
+ ParentRuleId = parentRuleId,
+ Type = def.Type
+ };
+ foreach (var cond in def.Conditions)
+ {
+ rule.Conditions.Add(new SegmentCondition
+ {
+ Property = cond.Property,
+ Operator = cond.Operator,
+ Value = cond.Value
+ });
+ }
+ db.SegmentRules.Add(rule);
+
+ if (def.ChildRules is not null)
+ {
+ foreach (var child in def.ChildRules)
+ {
+ AddRule(segmentId, child, rule.Id);
+ }
+ }
+ }
+
+ private static int CountConditions(SegmentRuleDefinition rule)
+ {
+ var count = rule.Conditions.Count;
+ if (rule.ChildRules is not null)
+ count += rule.ChildRules.Sum(CountConditions);
+ return count;
+ }
+}
diff --git a/src/api/MicCheck.Api/Segments/SegmentsController.cs b/src/api/MicCheck.Api/Segments/SegmentsController.cs
new file mode 100644
index 0000000..0f26b15
--- /dev/null
+++ b/src/api/MicCheck.Api/Segments/SegmentsController.cs
@@ -0,0 +1,90 @@
+using MicCheck.Api.Authorization;
+using MicCheck.Api.Common;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.RateLimiting;
+
+namespace MicCheck.Api.Segments;
+
+[ApiController]
+[Route("api/v1/projects/{projectId}/segments")]
+[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
+[EnableRateLimiting("AdminApi")]
+public class SegmentsController(SegmentService segmentService) : ControllerBase
+{
+ [HttpGet]
+ public async Task>> List(
+ int projectId,
+ [FromQuery] int page = 1,
+ [FromQuery] int pageSize = 20,
+ CancellationToken ct = default)
+ {
+ pageSize = Math.Clamp(pageSize, 1, 100);
+ var all = await segmentService.ListByProjectAsync(projectId, ct);
+ var paged = all.Skip((page - 1) * pageSize).Take(pageSize).Select(SegmentResponse.From).ToList();
+ return Ok(new PaginatedResponse(all.Count, null, null, paged));
+ }
+
+ [HttpPost]
+ public async Task> Create(
+ int projectId, CreateSegmentRequest request, CancellationToken ct)
+ {
+ try
+ {
+ var rules = MapRules(request.Rules);
+ var segment = await segmentService.CreateAsync(projectId, request.Name, rules, ct);
+ return CreatedAtAction(nameof(GetById), new { projectId, id = segment.Id }, SegmentResponse.From(segment));
+ }
+ catch (DomainException ex)
+ {
+ return BadRequest(new { error = ex.Message });
+ }
+ }
+
+ [HttpGet("{id}")]
+ public async Task> GetById(int projectId, int id, CancellationToken ct)
+ {
+ var segment = await segmentService.FindByIdAsync(id, ct);
+ if (segment is null || segment.ProjectId != projectId) return NotFound();
+ return Ok(SegmentResponse.From(segment));
+ }
+
+ [HttpPut("{id}")]
+ public async Task> Update(
+ int projectId, int id, CreateSegmentRequest request, CancellationToken ct)
+ {
+ var segment = await segmentService.FindByIdAsync(id, ct);
+ if (segment is null || segment.ProjectId != projectId) return NotFound();
+
+ try
+ {
+ var rules = MapRules(request.Rules);
+ var updated = await segmentService.UpdateAsync(id, request.Name, rules, ct);
+ return Ok(SegmentResponse.From(updated));
+ }
+ catch (DomainException ex)
+ {
+ return BadRequest(new { error = ex.Message });
+ }
+ }
+
+ [HttpDelete("{id}")]
+ public async Task Delete(int projectId, int id, CancellationToken ct)
+ {
+ var segment = await segmentService.FindByIdAsync(id, ct);
+ if (segment is null || segment.ProjectId != projectId) return NotFound();
+
+ await segmentService.DeleteAsync(id, ct);
+ return NoContent();
+ }
+
+ private static IReadOnlyList MapRules(IReadOnlyList rules) =>
+ rules.Select(r => new SegmentRuleDefinition(
+ Enum.Parse(r.Type, ignoreCase: true),
+ r.Conditions.Select(c => new SegmentConditionDefinition(
+ c.Property,
+ Enum.Parse(c.Operator, ignoreCase: true),
+ c.Value)).ToList(),
+ r.ChildRules is not null ? MapRules(r.ChildRules) : null
+ )).ToList();
+}
diff --git a/src/api/MicCheck.Api/Users/.gitkeep b/src/api/MicCheck.Api/Users/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/src/api/MicCheck.Api/Webhooks/.gitkeep b/src/api/MicCheck.Api/Webhooks/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/src/api/MicCheck.Api/Webhooks/CreateWebhookRequest.cs b/src/api/MicCheck.Api/Webhooks/CreateWebhookRequest.cs
new file mode 100644
index 0000000..85b36e5
--- /dev/null
+++ b/src/api/MicCheck.Api/Webhooks/CreateWebhookRequest.cs
@@ -0,0 +1,14 @@
+using FluentValidation;
+
+namespace MicCheck.Api.Webhooks;
+
+public record CreateWebhookRequest(string Url, string? Secret, bool Enabled);
+
+public class CreateWebhookRequestValidator : AbstractValidator
+{
+ public CreateWebhookRequestValidator()
+ {
+ RuleFor(x => x.Url).NotEmpty().MaximumLength(500).Must(u => Uri.TryCreate(u, UriKind.Absolute, out _))
+ .WithMessage("Url must be a valid absolute URL.");
+ }
+}
diff --git a/src/api/MicCheck.Api/Webhooks/WebhookResponse.cs b/src/api/MicCheck.Api/Webhooks/WebhookResponse.cs
new file mode 100644
index 0000000..c37f20e
--- /dev/null
+++ b/src/api/MicCheck.Api/Webhooks/WebhookResponse.cs
@@ -0,0 +1,23 @@
+namespace MicCheck.Api.Webhooks;
+
+public record WebhookResponse(
+ int Id,
+ string Url,
+ string? Secret,
+ string Scope,
+ bool Enabled,
+ int? EnvironmentId,
+ int? OrganizationId,
+ DateTimeOffset CreatedAt
+)
+{
+ public static WebhookResponse From(Webhook webhook) => new(
+ webhook.Id,
+ webhook.Url,
+ webhook.Secret,
+ webhook.Scope.ToString(),
+ webhook.Enabled,
+ webhook.EnvironmentId,
+ webhook.OrganizationId,
+ webhook.CreatedAt);
+}
diff --git a/src/api/MicCheck.Api/Webhooks/WebhookService.cs b/src/api/MicCheck.Api/Webhooks/WebhookService.cs
new file mode 100644
index 0000000..edfb91d
--- /dev/null
+++ b/src/api/MicCheck.Api/Webhooks/WebhookService.cs
@@ -0,0 +1,56 @@
+using MicCheck.Api.Data;
+using Microsoft.EntityFrameworkCore;
+
+namespace MicCheck.Api.Webhooks;
+
+public class WebhookService(MicCheckDbContext db)
+{
+ public async Task> ListByEnvironmentAsync(int environmentId, CancellationToken ct = default)
+ {
+ return await db.Webhooks
+ .Where(w => w.EnvironmentId == environmentId)
+ .ToListAsync(ct);
+ }
+
+ public async Task FindByIdAsync(int id, CancellationToken ct = default)
+ {
+ return await db.Webhooks.FirstOrDefaultAsync(w => w.Id == id, ct);
+ }
+
+ public async Task CreateAsync(int environmentId, string url, string? secret, bool enabled, CancellationToken ct = default)
+ {
+ var webhook = new Webhook
+ {
+ Url = url,
+ Secret = secret,
+ Scope = WebhookScope.Environment,
+ EnvironmentId = environmentId,
+ Enabled = enabled,
+ CreatedAt = DateTimeOffset.UtcNow
+ };
+ db.Webhooks.Add(webhook);
+ await db.SaveChangesAsync(ct);
+ return webhook;
+ }
+
+ public async Task UpdateAsync(int id, string url, string? secret, bool enabled, CancellationToken ct = default)
+ {
+ var webhook = await db.Webhooks.FirstOrDefaultAsync(w => w.Id == id, ct)
+ ?? throw new KeyNotFoundException($"Webhook {id} not found.");
+
+ webhook.Url = url;
+ webhook.Secret = secret;
+ webhook.Enabled = enabled;
+ await db.SaveChangesAsync(ct);
+ return webhook;
+ }
+
+ public async Task DeleteAsync(int id, CancellationToken ct = default)
+ {
+ var webhook = await db.Webhooks.FirstOrDefaultAsync(w => w.Id == id, ct);
+ if (webhook is null) return;
+
+ db.Webhooks.Remove(webhook);
+ await db.SaveChangesAsync(ct);
+ }
+}
diff --git a/src/infrastructure/MicCheck.Infrastructure/MicCheck.Infrastructure.csproj b/src/infrastructure/MicCheck.Infrastructure/MicCheck.Infrastructure.csproj
deleted file mode 100644
index 470926a..0000000
--- a/src/infrastructure/MicCheck.Infrastructure/MicCheck.Infrastructure.csproj
+++ /dev/null
@@ -1,20 +0,0 @@
-
-
-
- net10.0
- latest
- enable
- enable
- true
-
-
-
-
-
- all
- runtime; build; native; contentfiles; analyzers; buildtransitive
-
-
-
-
-
diff --git a/tests/api/MicCheck.Api.Tests.Unit/Admin/AdminApiIntegrationTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Admin/AdminApiIntegrationTests.cs
new file mode 100644
index 0000000..bd96c79
--- /dev/null
+++ b/tests/api/MicCheck.Api.Tests.Unit/Admin/AdminApiIntegrationTests.cs
@@ -0,0 +1,268 @@
+using System.Net;
+using System.Net.Http.Json;
+using MicCheck.Api.ApiKeys;
+using MicCheck.Api.Data;
+using MicCheck.Api.Features;
+using MicCheck.Api.Organizations;
+using MicCheck.Api.Projects;
+using Microsoft.Extensions.DependencyInjection;
+using NUnit.Framework;
+using AppEnvironment = MicCheck.Api.Environments.Environment;
+
+namespace MicCheck.Api.Tests.Unit.Admin;
+
+[TestFixture]
+public class AdminApiIntegrationTests
+{
+ private MicCheckWebApplicationFactory _factory = null!;
+ private string _rawApiKey = null!;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _factory = new MicCheckWebApplicationFactory();
+ _rawApiKey = SeedOrganizationWithApiKey();
+ }
+
+ [TearDown]
+ public void TearDown() => _factory.Dispose();
+
+ private string SeedOrganizationWithApiKey()
+ {
+ using var scope = _factory.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ var org = new Organization { Name = "Test Org", CreatedAt = DateTimeOffset.UtcNow };
+ db.Organizations.Add(org);
+ db.SaveChanges();
+
+ var rawKey = ApiKeyHasher.GenerateKey();
+ var hashedKey = ApiKeyHasher.Hash(rawKey);
+ var prefix = rawKey.Length >= 8 ? rawKey[..8] : rawKey;
+
+ db.ApiKeys.Add(new ApiKey
+ {
+ Key = hashedKey,
+ Prefix = prefix,
+ Name = "Test Key",
+ OrganizationId = org.Id,
+ IsActive = true,
+ CreatedAt = DateTimeOffset.UtcNow
+ });
+ db.SaveChanges();
+
+ return rawKey;
+ }
+
+ private HttpClient CreateAuthenticatedClient()
+ {
+ var client = _factory.CreateClient();
+ client.DefaultRequestHeaders.Add("Authorization", $"Api-Key {_rawApiKey}");
+ return client;
+ }
+
+ [Test]
+ public async Task WhenCreatingAProject_ThenProjectIsReturned()
+ {
+ var client = CreateAuthenticatedClient();
+
+ using var scope = _factory.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var org = db.Organizations.First();
+
+ var response = await client.PostAsJsonAsync("/api/v1/projects", new
+ {
+ name = "My Project",
+ organizationId = org.Id
+ });
+
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Created));
+ }
+
+ [Test]
+ public async Task WhenCreatingAFeature_ThenFeatureIsReturned()
+ {
+ var client = CreateAuthenticatedClient();
+
+ using var scope = _factory.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var org = db.Organizations.First();
+
+ var project = new Project
+ {
+ Name = "My Project",
+ OrganizationId = org.Id,
+ CreatedAt = DateTimeOffset.UtcNow
+ };
+ db.Projects.Add(project);
+ db.SaveChanges();
+
+ var response = await client.PostAsJsonAsync($"/api/v1/projects/{project.Id}/features", new
+ {
+ name = "dark_mode",
+ type = "Standard",
+ initialValue = (string?)null,
+ description = (string?)null
+ });
+
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Created));
+ }
+
+ [Test]
+ public async Task WhenCreatingAFeature_ThenFeatureStateIsAutoCreatedForEnvironments()
+ {
+ var client = CreateAuthenticatedClient();
+
+ using var scope = _factory.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var org = db.Organizations.First();
+
+ var project = new Project
+ {
+ Name = "My Project",
+ OrganizationId = org.Id,
+ CreatedAt = DateTimeOffset.UtcNow
+ };
+ db.Projects.Add(project);
+ db.SaveChanges();
+
+ db.Environments.Add(new AppEnvironment
+ {
+ Name = "Production",
+ ApiKey = "env-key-prod",
+ ProjectId = project.Id,
+ CreatedAt = DateTimeOffset.UtcNow
+ });
+ db.SaveChanges();
+
+ await client.PostAsJsonAsync($"/api/v1/projects/{project.Id}/features", new
+ {
+ name = "flag_x",
+ type = "Standard",
+ initialValue = (string?)null,
+ description = (string?)null
+ });
+
+ using var verifyScope = _factory.Services.CreateScope();
+ var verifyDb = verifyScope.ServiceProvider.GetRequiredService();
+ var feature = verifyDb.Features.First(f => f.Name == "flag_x");
+ var states = verifyDb.FeatureStates.Where(fs => fs.FeatureId == feature.Id).ToList();
+
+ Assert.That(states, Has.Count.EqualTo(1));
+ }
+
+ [Test]
+ public async Task WhenCreatingAnEnvironment_ThenFeatureStateIsAutoCreatedForExistingFeatures()
+ {
+ var client = CreateAuthenticatedClient();
+
+ using var scope = _factory.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var org = db.Organizations.First();
+
+ var project = new Project
+ {
+ Name = "My Project",
+ OrganizationId = org.Id,
+ CreatedAt = DateTimeOffset.UtcNow
+ };
+ db.Projects.Add(project);
+ db.Features.Add(new Feature
+ {
+ Name = "existing_flag",
+ ProjectId = project.Id,
+ CreatedAt = DateTimeOffset.UtcNow
+ });
+ db.SaveChanges();
+
+ var response = await client.PostAsJsonAsync("/api/v1/environments", new
+ {
+ name = "Staging",
+ projectId = project.Id
+ });
+
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Created));
+
+ using var verifyScope = _factory.Services.CreateScope();
+ var verifyDb = verifyScope.ServiceProvider.GetRequiredService();
+ var feature = verifyDb.Features.First(f => f.Name == "existing_flag");
+ var env = verifyDb.Environments.First(e => e.ProjectId == project.Id);
+ var states = verifyDb.FeatureStates.Where(fs => fs.EnvironmentId == env.Id && fs.FeatureId == feature.Id).ToList();
+
+ Assert.That(states, Has.Count.EqualTo(1));
+ }
+
+ [Test]
+ public async Task WhenRequestIsMissingApiKey_ThenUnauthorizedIsReturned()
+ {
+ var client = _factory.CreateClient();
+
+ var response = await client.GetAsync("/api/v1/projects?organizationId=1");
+
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
+ }
+
+ [Test]
+ public async Task WhenCreatingAFeatureWithInvalidName_ThenUnprocessableEntityIsReturned()
+ {
+ var client = CreateAuthenticatedClient();
+
+ using var scope = _factory.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var org = db.Organizations.First();
+
+ var project = new Project
+ {
+ Name = "My Project",
+ OrganizationId = org.Id,
+ CreatedAt = DateTimeOffset.UtcNow
+ };
+ db.Projects.Add(project);
+ db.SaveChanges();
+
+ var response = await client.PostAsJsonAsync($"/api/v1/projects/{project.Id}/features", new
+ {
+ name = "invalid name with spaces!",
+ type = "Standard"
+ });
+
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.UnprocessableEntity));
+ }
+
+ [Test]
+ public async Task WhenCreatingASegment_ThenSegmentIsReturned()
+ {
+ var client = CreateAuthenticatedClient();
+
+ using var scope = _factory.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var org = db.Organizations.First();
+
+ var project = new Project
+ {
+ Name = "My Project",
+ OrganizationId = org.Id,
+ CreatedAt = DateTimeOffset.UtcNow
+ };
+ db.Projects.Add(project);
+ db.SaveChanges();
+
+ var response = await client.PostAsJsonAsync($"/api/v1/projects/{project.Id}/segments", new
+ {
+ name = "Premium Users",
+ rules = new[]
+ {
+ new
+ {
+ type = "All",
+ conditions = new[]
+ {
+ new { property = "plan", @operator = "Equal", value = "premium" }
+ }
+ }
+ }
+ });
+
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Created));
+ }
+}
diff --git a/tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentServiceTests.cs
new file mode 100644
index 0000000..acba748
--- /dev/null
+++ b/tests/api/MicCheck.Api.Tests.Unit/Environments/EnvironmentServiceTests.cs
@@ -0,0 +1,167 @@
+using MicCheck.Api.Audit;
+using MicCheck.Api.Data;
+using MicCheck.Api.Environments;
+using MicCheck.Api.Features;
+using Microsoft.EntityFrameworkCore;
+using Moq;
+using NUnit.Framework;
+using AppEnvironment = MicCheck.Api.Environments.Environment;
+
+namespace MicCheck.Api.Tests.Unit.Environments;
+
+[TestFixture]
+public class EnvironmentServiceTests
+{
+ private MicCheckDbContext _db = null!;
+ private EnvironmentService _service = null!;
+ private const int OrganizationId = 1;
+ private const int ProjectId = 1;
+
+ [SetUp]
+ public void SetUp()
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseInMemoryDatabase(Guid.NewGuid().ToString())
+ .Options;
+ _db = new MicCheckDbContext(options);
+
+ var auditService = new Mock(_db, null!);
+ auditService.Setup(a => a.LogAsync(
+ It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny()))
+ .Returns(Task.CompletedTask);
+
+ _service = new EnvironmentService(_db, auditService.Object);
+
+ SeedBaseData();
+ }
+
+ [TearDown]
+ public void TearDown() => _db.Dispose();
+
+ private void SeedBaseData()
+ {
+ _db.Organizations.Add(new MicCheck.Api.Organizations.Organization
+ {
+ Id = OrganizationId,
+ Name = "Test Org",
+ CreatedAt = DateTimeOffset.UtcNow
+ });
+ _db.Projects.Add(new MicCheck.Api.Projects.Project
+ {
+ Id = ProjectId,
+ Name = "Test Project",
+ OrganizationId = OrganizationId,
+ CreatedAt = DateTimeOffset.UtcNow
+ });
+ _db.SaveChanges();
+ }
+
+ [Test]
+ public async Task WhenCreatingAnEnvironment_ThenFeatureStateIsCreatedForEachExistingFeature()
+ {
+ _db.Features.Add(new Feature
+ {
+ Name = "feature_a",
+ ProjectId = ProjectId,
+ InitialValue = "hello",
+ CreatedAt = DateTimeOffset.UtcNow
+ });
+ _db.SaveChanges();
+
+ var environment = await _service.CreateAsync(ProjectId, "Staging");
+
+ var states = await _db.FeatureStates.Where(fs => fs.EnvironmentId == environment.Id).ToListAsync();
+ Assert.That(states, Has.Count.EqualTo(1));
+ Assert.That(states[0].Value, Is.EqualTo("hello"));
+ }
+
+ [Test]
+ public async Task WhenCreatingAnEnvironment_ThenAUniqueApiKeyIsGenerated()
+ {
+ var env1 = await _service.CreateAsync(ProjectId, "Env1");
+ var env2 = await _service.CreateAsync(ProjectId, "Env2");
+
+ Assert.That(env1.ApiKey, Is.Not.EqualTo(env2.ApiKey));
+ }
+
+ [Test]
+ public async Task WhenCloningAnEnvironment_ThenEnvironmentLevelFeatureStatesAreCopied()
+ {
+ var source = await _service.CreateAsync(ProjectId, "Production");
+
+ var feature = new Feature { Name = "flag", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow };
+ _db.Features.Add(feature);
+ _db.SaveChanges();
+
+ _db.FeatureStates.Add(new FeatureState
+ {
+ FeatureId = feature.Id,
+ EnvironmentId = source.Id,
+ Enabled = true,
+ Value = "prod-value",
+ CreatedAt = DateTimeOffset.UtcNow,
+ UpdatedAt = DateTimeOffset.UtcNow
+ });
+ _db.SaveChanges();
+
+ var cloned = await _service.CloneAsync(source.ApiKey, "Staging");
+
+ var clonedStates = await _db.FeatureStates
+ .Where(fs => fs.EnvironmentId == cloned.Id)
+ .ToListAsync();
+
+ Assert.That(clonedStates, Has.Count.EqualTo(1));
+ Assert.That(clonedStates[0].Value, Is.EqualTo("prod-value"));
+ Assert.That(clonedStates[0].Enabled, Is.True);
+ }
+
+ [Test]
+ public async Task WhenCloningAnEnvironment_ThenIdentityOverridesAreNotCopied()
+ {
+ var source = await _service.CreateAsync(ProjectId, "Production");
+
+ var feature = new Feature { Name = "flag", ProjectId = ProjectId, CreatedAt = DateTimeOffset.UtcNow };
+ _db.Features.Add(feature);
+ _db.SaveChanges();
+
+ var identity = new MicCheck.Api.Identities.Identity
+ {
+ Identifier = "user-1",
+ EnvironmentId = source.Id,
+ CreatedAt = DateTimeOffset.UtcNow
+ };
+ _db.Identities.Add(identity);
+ _db.SaveChanges();
+
+ _db.FeatureStates.Add(new FeatureState
+ {
+ FeatureId = feature.Id,
+ EnvironmentId = source.Id,
+ IdentityId = identity.Id,
+ Enabled = true,
+ CreatedAt = DateTimeOffset.UtcNow,
+ UpdatedAt = DateTimeOffset.UtcNow
+ });
+ _db.SaveChanges();
+
+ var cloned = await _service.CloneAsync(source.ApiKey, "Staging");
+
+ var clonedStates = await _db.FeatureStates
+ .Where(fs => fs.EnvironmentId == cloned.Id)
+ .ToListAsync();
+
+ Assert.That(clonedStates, Is.Empty);
+ }
+
+ [Test]
+ public async Task WhenCloningAnEnvironment_ThenClonedEnvironmentHasDifferentApiKey()
+ {
+ var source = await _service.CreateAsync(ProjectId, "Production");
+
+ var cloned = await _service.CloneAsync(source.ApiKey, "Staging");
+
+ Assert.That(cloned.ApiKey, Is.Not.EqualTo(source.ApiKey));
+ }
+}
diff --git a/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureServiceTests.cs
new file mode 100644
index 0000000..8fc2d80
--- /dev/null
+++ b/tests/api/MicCheck.Api.Tests.Unit/Features/FeatureServiceTests.cs
@@ -0,0 +1,139 @@
+using MicCheck.Api.Audit;
+using MicCheck.Api.Data;
+using MicCheck.Api.Features;
+using MicCheck.Api.Common;
+using Microsoft.EntityFrameworkCore;
+using Moq;
+using NUnit.Framework;
+using AppEnvironment = MicCheck.Api.Environments.Environment;
+
+namespace MicCheck.Api.Tests.Unit.Features;
+
+[TestFixture]
+public class FeatureServiceTests
+{
+ private MicCheckDbContext _db = null!;
+ private FeatureService _service = null!;
+ private const int OrganizationId = 1;
+ private const int ProjectId = 1;
+ private const int EnvironmentId = 1;
+
+ [SetUp]
+ public void SetUp()
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseInMemoryDatabase(Guid.NewGuid().ToString())
+ .Options;
+ _db = new MicCheckDbContext(options);
+
+ var auditService = new Mock(_db, null!);
+ auditService.Setup(a => a.LogAsync(
+ 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);
+
+ SeedBaseData();
+ }
+
+ [TearDown]
+ public void TearDown() => _db.Dispose();
+
+ private void SeedBaseData()
+ {
+ _db.Organizations.Add(new MicCheck.Api.Organizations.Organization
+ {
+ Id = OrganizationId,
+ Name = "Test Org",
+ CreatedAt = DateTimeOffset.UtcNow
+ });
+ _db.Projects.Add(new MicCheck.Api.Projects.Project
+ {
+ Id = ProjectId,
+ Name = "Test Project",
+ OrganizationId = OrganizationId,
+ CreatedAt = DateTimeOffset.UtcNow
+ });
+ _db.Environments.Add(new AppEnvironment
+ {
+ Id = EnvironmentId,
+ Name = "Production",
+ ApiKey = "test-key",
+ ProjectId = ProjectId,
+ CreatedAt = DateTimeOffset.UtcNow
+ });
+ _db.SaveChanges();
+ }
+
+ [Test]
+ public async Task WhenCreatingAFeature_ThenFeatureStateIsCreatedForEachEnvironment()
+ {
+ var feature = await _service.CreateAsync(ProjectId, "dark_mode", FeatureType.Standard, null, null);
+
+ var states = await _db.FeatureStates.Where(fs => fs.FeatureId == feature.Id).ToListAsync();
+ Assert.That(states, Has.Count.EqualTo(1));
+ Assert.That(states[0].EnvironmentId, Is.EqualTo(EnvironmentId));
+ }
+
+ [Test]
+ public async Task WhenCreatingAFeatureWithInitialValue_ThenFeatureStateHasValue()
+ {
+ var feature = await _service.CreateAsync(ProjectId, "flag", FeatureType.Standard, "initial-val", null);
+
+ var state = await _db.FeatureStates.FirstAsync(fs => fs.FeatureId == feature.Id);
+ Assert.That(state.Value, Is.EqualTo("initial-val"));
+ }
+
+ [Test]
+ public async Task WhenProjectHas400Features_ThenCreatingAnotherThrowsDomainException()
+ {
+ for (var i = 0; i < 400; i++)
+ {
+ _db.Features.Add(new Feature
+ {
+ Name = $"feature_{i}",
+ ProjectId = ProjectId,
+ CreatedAt = DateTimeOffset.UtcNow
+ });
+ }
+ _db.SaveChanges();
+
+ Assert.ThrowsAsync(() =>
+ _service.CreateAsync(ProjectId, "overflow_feature", FeatureType.Standard, null, null));
+ }
+
+ [Test]
+ public async Task WhenUpdatingAFeature_ThenNameAndDescriptionAreChanged()
+ {
+ var feature = await _service.CreateAsync(ProjectId, "old_name", FeatureType.Standard, null, "old desc");
+
+ var updated = await _service.UpdateAsync(feature.Id, "new_name", "new desc");
+
+ Assert.That(updated.Name, Is.EqualTo("new_name"));
+ Assert.That(updated.Description, Is.EqualTo("new desc"));
+ }
+
+ [Test]
+ public async Task WhenDeletingAFeature_ThenItIsRemovedFromTheDatabase()
+ {
+ var feature = await _service.CreateAsync(ProjectId, "to_delete", FeatureType.Standard, null, null);
+
+ await _service.DeleteAsync(feature.Id);
+
+ var found = await _db.Features.FirstOrDefaultAsync(f => f.Id == feature.Id);
+ Assert.That(found, Is.Null);
+ }
+
+ [Test]
+ public async Task WhenListingFeatures_ThenAllProjectFeaturesAreReturned()
+ {
+ await _service.CreateAsync(ProjectId, "feature_a", FeatureType.Standard, null, null);
+ await _service.CreateAsync(ProjectId, "feature_b", FeatureType.Standard, null, null);
+
+ var features = await _service.ListByProjectAsync(ProjectId);
+
+ Assert.That(features, Has.Count.EqualTo(2));
+ }
+}
diff --git a/tests/api/MicCheck.Api.Tests.Unit/MicCheck.Api.Tests.Unit.csproj b/tests/api/MicCheck.Api.Tests.Unit/MicCheck.Api.Tests.Unit.csproj
index 383c7de..71dba73 100644
--- a/tests/api/MicCheck.Api.Tests.Unit/MicCheck.Api.Tests.Unit.csproj
+++ b/tests/api/MicCheck.Api.Tests.Unit/MicCheck.Api.Tests.Unit.csproj
@@ -21,7 +21,6 @@
-
diff --git a/tests/api/MicCheck.Api.Tests.Unit/Segments/SegmentServiceTests.cs b/tests/api/MicCheck.Api.Tests.Unit/Segments/SegmentServiceTests.cs
new file mode 100644
index 0000000..b804cc8
--- /dev/null
+++ b/tests/api/MicCheck.Api.Tests.Unit/Segments/SegmentServiceTests.cs
@@ -0,0 +1,152 @@
+using MicCheck.Api.Audit;
+using MicCheck.Api.Common;
+using MicCheck.Api.Data;
+using MicCheck.Api.Segments;
+using Microsoft.EntityFrameworkCore;
+using Moq;
+using NUnit.Framework;
+
+namespace MicCheck.Api.Tests.Unit.Segments;
+
+[TestFixture]
+public class SegmentServiceTests
+{
+ private MicCheckDbContext _db = null!;
+ private SegmentService _service = null!;
+ private const int OrganizationId = 1;
+ private const int ProjectId = 1;
+
+ [SetUp]
+ public void SetUp()
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseInMemoryDatabase(Guid.NewGuid().ToString())
+ .Options;
+ _db = new MicCheckDbContext(options);
+
+ var auditService = new Mock(_db, null!);
+ auditService.Setup(a => a.LogAsync(
+ It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny()))
+ .Returns(Task.CompletedTask);
+
+ _service = new SegmentService(_db, auditService.Object);
+
+ SeedBaseData();
+ }
+
+ [TearDown]
+ public void TearDown() => _db.Dispose();
+
+ private void SeedBaseData()
+ {
+ _db.Organizations.Add(new MicCheck.Api.Organizations.Organization
+ {
+ Id = OrganizationId,
+ Name = "Test Org",
+ CreatedAt = DateTimeOffset.UtcNow
+ });
+ _db.Projects.Add(new MicCheck.Api.Projects.Project
+ {
+ Id = ProjectId,
+ Name = "Test Project",
+ OrganizationId = OrganizationId,
+ CreatedAt = DateTimeOffset.UtcNow
+ });
+ _db.SaveChanges();
+ }
+
+ private static SegmentRuleDefinition SingleConditionRule(
+ string property = "plan",
+ SegmentConditionOperator op = SegmentConditionOperator.Equal,
+ string value = "premium") =>
+ new(SegmentRuleType.All,
+ [new SegmentConditionDefinition(property, op, value)]);
+
+ [Test]
+ public async Task WhenCreatingASegment_ThenSegmentAndRulesArePersisted()
+ {
+ var rules = new[] { SingleConditionRule() };
+ var segment = await _service.CreateAsync(ProjectId, "Premium Users", rules);
+
+ var loaded = await _db.Segments
+ .Include(s => s.Rules)
+ .ThenInclude(r => r.Conditions)
+ .FirstAsync(s => s.Id == segment.Id);
+
+ Assert.That(loaded.Name, Is.EqualTo("Premium Users"));
+ Assert.That(loaded.Rules, Has.Count.EqualTo(1));
+ Assert.That(loaded.Rules.First().Conditions, Has.Count.EqualTo(1));
+ }
+
+ [Test]
+ public async Task WhenProjectHas100Segments_ThenCreatingAnotherThrowsDomainException()
+ {
+ for (var i = 0; i < 100; i++)
+ {
+ _db.Segments.Add(new Segment
+ {
+ Name = $"segment_{i}",
+ ProjectId = ProjectId,
+ CreatedAt = DateTimeOffset.UtcNow
+ });
+ }
+ _db.SaveChanges();
+
+ Assert.ThrowsAsync(() =>
+ _service.CreateAsync(ProjectId, "overflow_segment", [SingleConditionRule()]));
+ }
+
+ [Test]
+ public async Task WhenSegmentHasMoreThan100Conditions_ThenCreatingThrowsDomainException()
+ {
+ var conditions = Enumerable.Range(0, 101)
+ .Select(i => new SegmentConditionDefinition($"prop_{i}", SegmentConditionOperator.Equal, "val"))
+ .ToList();
+ var rules = new[] { new SegmentRuleDefinition(SegmentRuleType.All, conditions) };
+
+ Assert.ThrowsAsync(() =>
+ _service.CreateAsync(ProjectId, "Too Many Conditions", rules));
+ }
+
+ [Test]
+ public async Task WhenUpdatingASegment_ThenOldRulesAreReplacedWithNewRules()
+ {
+ var segment = await _service.CreateAsync(ProjectId, "Segment", [SingleConditionRule("plan")]);
+
+ var newRules = new[] { SingleConditionRule("country") };
+ var updated = await _service.UpdateAsync(segment.Id, "Updated Segment", newRules);
+
+ var loaded = await _db.Segments
+ .Include(s => s.Rules)
+ .ThenInclude(r => r.Conditions)
+ .FirstAsync(s => s.Id == updated.Id);
+
+ Assert.That(loaded.Name, Is.EqualTo("Updated Segment"));
+ Assert.That(loaded.Rules, Has.Count.EqualTo(1));
+ Assert.That(loaded.Rules.First().Conditions.First().Property, Is.EqualTo("country"));
+ }
+
+ [Test]
+ public async Task WhenDeletingASegment_ThenItIsRemovedFromTheDatabase()
+ {
+ var segment = await _service.CreateAsync(ProjectId, "To Delete", [SingleConditionRule()]);
+
+ await _service.DeleteAsync(segment.Id);
+
+ var found = await _db.Segments.FirstOrDefaultAsync(s => s.Id == segment.Id);
+ Assert.That(found, Is.Null);
+ }
+
+ [Test]
+ public async Task WhenListingSegments_ThenAllProjectSegmentsAreReturned()
+ {
+ await _service.CreateAsync(ProjectId, "Segment A", [SingleConditionRule()]);
+ await _service.CreateAsync(ProjectId, "Segment B", [SingleConditionRule()]);
+
+ var segments = await _service.ListByProjectAsync(ProjectId);
+
+ Assert.That(segments, Has.Count.EqualTo(2));
+ }
+}
diff --git a/tests/infrastructure/MicCheck.Infrastructure.Tests/MicCheck.Infrastructure.Tests.csproj b/tests/infrastructure/MicCheck.Infrastructure.Tests/MicCheck.Infrastructure.Tests.csproj
deleted file mode 100644
index 6b7f3c0..0000000
--- a/tests/infrastructure/MicCheck.Infrastructure.Tests/MicCheck.Infrastructure.Tests.csproj
+++ /dev/null
@@ -1,29 +0,0 @@
-
-
-
- net10.0
- latest
- enable
- enable
- false
- true
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/tests/infrastructure/MicCheck.Infrastructure.Tests/UnitTest1.cs b/tests/infrastructure/MicCheck.Infrastructure.Tests/UnitTest1.cs
deleted file mode 100644
index 47992ac..0000000
--- a/tests/infrastructure/MicCheck.Infrastructure.Tests/UnitTest1.cs
+++ /dev/null
@@ -1,15 +0,0 @@
-namespace MicCheck.Infrastructure.Tests;
-
-public class Tests
-{
- [SetUp]
- public void Setup()
- {
- }
-
- [Test]
- public void Test1()
- {
- Assert.Pass();
- }
-}