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