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