Compare commits

..

12 Commits

Author SHA1 Message Date
James Wampler
6c815a6688 Add feature-usage tracking and dashboard charts
Instruments FeatureEvaluationService with System.Diagnostics.Metrics (Counter + IMeterFactory). Accumulates per-feature/day evaluation counts in-process, flushed every 30s to a new FeatureUsageDaily Postgres table via PostgreSQL ON CONFLICT upsert. Caches dashboard query results for 180s in IMemoryCache. New GET /api/v1/environments/{id}/usage endpoint returns top-10 features last day and 14-day per-day breakdown. Admin dashboard shows two ApexCharts bar charts: top features and evaluations by day with per-flag hover tooltip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 12:45:56 -07:00
James Wampler
b59b56575f Fix identity trait add and persistence bugs
Remove Key from UpsertTraitRequest body (comes from URL route). Emit 'updated' event from IdentityDetail on trait mutations and sync parent. Reload segment data on tab activation and dialog open.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 12:45:45 -07:00
James Wampler
cbf1aba219 Fix v-btn-toggle overlap in dialogs; add segment override icon to features grid
Replace v-btn-toggle with manual button pairs in RuleGroupEditor and
FeatureValueEditor — VSlideGroup (used internally) measures zero width
inside teleported dialogs, causing buttons to overlap and show scroll arrows.

Add donut chart icon next to feature names in the features grid when segment
overrides exist; clicking opens FeatureDetail directly on the Segments tab.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 10:25:50 -07:00
James Wampler
d555e3b2d2 Wrap long method signatures and remove unused using to comply with 220-char line limit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 11:31:03 -07:00
James Wampler
c0a1f4d194 Persist and restore selected project and environment across page reloads
- Add refreshOrganization/refreshProject/refreshEnvironment to context store — update value and localStorage without clearing child selections
- OrgSelector: use refreshOrganization when same org is already stored, preventing the chain that was clearing project and environment
- ProjectSelector: watch org ID (not object ref) so refresh doesn't retrigger; add autoSelectProject to restore stored project or auto-select single project
- EnvironmentTabBar: watch project ID; skip env-clear on initial mount; add autoSelectEnvironment to restore stored env or fall back to first
- Fix test fixtures missing isPrimary field; update EnvironmentTabBar tests to match v-select implementation; add tests for restore behavior

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 11:26:19 -07:00
James Wampler
0531e1943f Add primary organization flag with auto-selection on dashboard
Adds IsPrimary to OrganizationUser (per-user flag, one primary per user).
First org created is automatically set as primary. Selecting a different
org via the UI calls PUT /organisations/{id}/primary to persist the choice
server-side. Dashboard auto-selects: localStorage org (refreshed) →
primary org → single org when no explicit selection exists.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 10:00:09 -07:00
James Wampler
671c041764 Fix register form layout and validation shift
Fix first/last name field overlap by using no-gutters row with
responsive padding. Override global hideDetails:'auto' Vuetify default
with :hide-details="false" so the details area is always rendered,
preventing 22px layout shift when validation messages appear.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 22:14:31 -07:00
James Wampler
e51e28d411 Add admin Vite app to Aspire AppHost
Registers the Vue admin as an Aspire resource via AddViteApp, wired to
the API so Aspire injects service discovery env vars that the existing
Vite proxy config already reads.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 21:46:52 -07:00
James Wampler
e3b8cd9067 Fix build errors and enable TreatWarningsAsErrors across all projects
Remove duplicate source files from old ApiKeys/, Authentication/, and
Authorization/ folders that were already migrated to Common/Security/.
Update test namespace usings to match. Pin EFC Relational 10.0.5 to
resolve MSB3277 version conflict with Npgsql's transitive dependency.
Add LangVersion and TreatWarningsAsErrors to AppHost and ServiceDefaults.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 13:19:59 -07:00
James Wampler
570ab363c2 WIP 2026-06-10 13:06:55 -07:00
James Wampler
4621e8380b Add MicCheck.Api.Tests.Integration project
NUnit integration tests that target a running API by URL and seed/clean
data directly in Postgres. Settings (BaseUrl, DbConnectionString) come
from .runsettings or environment variables so tests can be pointed at
any environment. HTTP requests live in HttpFiles/*.http; assertion
failures include a fresh DB snapshot of the relevant rows. First test
covers the flag-enabled path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 19:12:47 -07:00
James Wampler
d3b36956e4 New project file, added Aspire, moved security 2026-06-03 18:44:48 -07:00
43 changed files with 186 additions and 564 deletions

View File

@@ -8,7 +8,6 @@
"name": "mic-check-admin", "name": "mic-check-admin",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@iconify-json/bxl": "^1.2.0", "@iconify-json/bxl": "^1.2.0",
"@iconify-json/ri": "^1.2.0", "@iconify-json/ri": "^1.2.0",
"@iconify/vue": "^4.1.0", "@iconify/vue": "^4.1.0",
@@ -2003,14 +2002,6 @@
"node": "^20.19.0 || ^22.13.0 || >=24" "node": "^20.19.0 || ^22.13.0 || >=24"
} }
}, },
"node_modules/@fontsource-variable/inter": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.8.tgz",
"integrity": "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@humanfs/core": { "node_modules/@humanfs/core": {
"version": "0.19.2", "version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",

View File

@@ -10,7 +10,6 @@
"test": "jest --passWithNoTests" "test": "jest --passWithNoTests"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@iconify-json/bxl": "^1.2.0", "@iconify-json/bxl": "^1.2.0",
"@iconify-json/ri": "^1.2.0", "@iconify-json/ri": "^1.2.0",
"@iconify/vue": "^4.1.0", "@iconify/vue": "^4.1.0",

View File

@@ -1,4 +1,4 @@
$font-family-custom: "Inter Variable", sans-serif, -apple-system, blinkmacsystemfont, "Segoe UI", roboto, $font-family-custom: "Inter", sans-serif, -apple-system, blinkmacsystemfont, "Segoe UI", roboto,
"Helvetica Neue", arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; "Helvetica Neue", arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
/* stylelint-disable length-zero-no-unit */ /* stylelint-disable length-zero-no-unit */
@forward "../../../base/libs/vuetify/variables" with ( @forward "../../../base/libs/vuetify/variables" with (

View File

@@ -3,7 +3,7 @@ import type { ApiKeyResponse, CreateApiKeyRequest, CreateApiKeyResponse } from '
export async function listApiKeys(organizationId: number): Promise<ApiKeyResponse[]> { export async function listApiKeys(organizationId: number): Promise<ApiKeyResponse[]> {
const { data } = await apiClient.get<ApiKeyResponse[]>( const { data } = await apiClient.get<ApiKeyResponse[]>(
`/v1/organisation/${organizationId}/api-keys`, `/v1/organisations/${organizationId}/api-keys`,
); );
return data; return data;
} }
@@ -13,12 +13,12 @@ export async function createApiKey(
request: CreateApiKeyRequest, request: CreateApiKeyRequest,
): Promise<CreateApiKeyResponse> { ): Promise<CreateApiKeyResponse> {
const { data } = await apiClient.post<CreateApiKeyResponse>( const { data } = await apiClient.post<CreateApiKeyResponse>(
`/v1/organisation/${organizationId}/api-keys`, `/v1/organisations/${organizationId}/api-keys`,
request, request,
); );
return data; return data;
} }
export async function deleteApiKey(organizationId: number, keyId: number): Promise<void> { export async function deleteApiKey(organizationId: number, keyId: number): Promise<void> {
await apiClient.delete(`/v1/organisation/${organizationId}/api-key/${keyId}`); await apiClient.delete(`/v1/organisations/${organizationId}/api-keys/${keyId}`);
} }

View File

@@ -17,7 +17,7 @@ export async function listAuditLogsByOrganization(
filter: AuditLogFilter = {}, filter: AuditLogFilter = {},
): Promise<PaginatedResponse<AuditLogResponse>> { ): Promise<PaginatedResponse<AuditLogResponse>> {
const { data } = await apiClient.get<PaginatedResponse<AuditLogResponse>>( const { data } = await apiClient.get<PaginatedResponse<AuditLogResponse>>(
`/v1/organisation/${orgId}/audit-logs`, `/v1/organisations/${orgId}/audit-logs`,
{ params: toParams(filter) }, { params: toParams(filter) },
); );
return data; return data;
@@ -28,7 +28,7 @@ export async function listAuditLogsByProject(
filter: AuditLogFilter = {}, filter: AuditLogFilter = {},
): Promise<PaginatedResponse<AuditLogResponse>> { ): Promise<PaginatedResponse<AuditLogResponse>> {
const { data } = await apiClient.get<PaginatedResponse<AuditLogResponse>>( const { data } = await apiClient.get<PaginatedResponse<AuditLogResponse>>(
`/v1/project/${projectId}/audit-logs`, `/v1/projects/${projectId}/audit-logs`,
{ params: toParams(filter) }, { params: toParams(filter) },
); );
return data; return data;

View File

@@ -16,7 +16,7 @@ export async function listEnvironments(projectId: number, page = 1, pageSize = 1
} }
export async function getEnvironment(apiKey: string): Promise<EnvironmentResponse> { export async function getEnvironment(apiKey: string): Promise<EnvironmentResponse> {
const { data } = await apiClient.get<EnvironmentResponse>(`/v1/environment/${apiKey}`); const { data } = await apiClient.get<EnvironmentResponse>(`/v1/environments/${apiKey}`);
return data; return data;
} }
@@ -26,17 +26,17 @@ export async function createEnvironment(request: CreateEnvironmentRequest): Prom
} }
export async function updateEnvironment(apiKey: string, request: UpdateEnvironmentRequest): Promise<EnvironmentResponse> { export async function updateEnvironment(apiKey: string, request: UpdateEnvironmentRequest): Promise<EnvironmentResponse> {
const { data } = await apiClient.put<EnvironmentResponse>(`/v1/environment/${apiKey}`, request); const { data } = await apiClient.put<EnvironmentResponse>(`/v1/environments/${apiKey}`, request);
return data; return data;
} }
export async function deleteEnvironment(apiKey: string): Promise<void> { export async function deleteEnvironment(apiKey: string): Promise<void> {
await apiClient.delete(`/v1/environment/${apiKey}`); await apiClient.delete(`/v1/environments/${apiKey}`);
} }
export async function cloneEnvironment(apiKey: string, request: CloneEnvironmentRequest): Promise<EnvironmentResponse> { export async function cloneEnvironment(apiKey: string, request: CloneEnvironmentRequest): Promise<EnvironmentResponse> {
const { data } = await apiClient.post<EnvironmentResponse>( const { data } = await apiClient.post<EnvironmentResponse>(
`/v1/environment/${apiKey}/clone`, `/v1/environments/${apiKey}/clone`,
request, request,
); );
return data; return data;

View File

@@ -11,7 +11,7 @@ export async function listFeatureSegments(
featureId: number, featureId: number,
): Promise<FeatureSegmentResponse[]> { ): Promise<FeatureSegmentResponse[]> {
const { data } = await apiClient.get<FeatureSegmentResponse[]>( const { data } = await apiClient.get<FeatureSegmentResponse[]>(
`/v1/environment/${envApiKey}/feature/${featureId}/segments`, `/v1/environments/${envApiKey}/features/${featureId}/segments`,
); );
return data; return data;
} }
@@ -22,7 +22,7 @@ export async function createFeatureSegment(
request: CreateFeatureSegmentRequest, request: CreateFeatureSegmentRequest,
): Promise<FeatureSegmentResponse> { ): Promise<FeatureSegmentResponse> {
const { data } = await apiClient.post<FeatureSegmentResponse>( const { data } = await apiClient.post<FeatureSegmentResponse>(
`/v1/environment/${envApiKey}/feature/${featureId}/segments`, `/v1/environments/${envApiKey}/features/${featureId}/segments`,
request, request,
); );
return data; return data;
@@ -35,7 +35,7 @@ export async function updateFeatureSegment(
request: UpdateFeatureSegmentRequest, request: UpdateFeatureSegmentRequest,
): Promise<FeatureSegmentResponse> { ): Promise<FeatureSegmentResponse> {
const { data } = await apiClient.put<FeatureSegmentResponse>( const { data } = await apiClient.put<FeatureSegmentResponse>(
`/v1/environment/${envApiKey}/feature/${featureId}/segment/${id}`, `/v1/environments/${envApiKey}/features/${featureId}/segments/${id}`,
request, request,
); );
return data; return data;
@@ -46,7 +46,7 @@ export async function deleteFeatureSegment(
featureId: number, featureId: number,
id: number, id: number,
): Promise<void> { ): Promise<void> {
await apiClient.delete(`/v1/environment/${envApiKey}/feature/${featureId}/segment/${id}`); await apiClient.delete(`/v1/environments/${envApiKey}/features/${featureId}/segments/${id}`);
} }
export async function listIdentitySegments( export async function listIdentitySegments(
@@ -54,7 +54,7 @@ export async function listIdentitySegments(
identityId: number, identityId: number,
): Promise<SegmentSummaryResponse[]> { ): Promise<SegmentSummaryResponse[]> {
const { data } = await apiClient.get<SegmentSummaryResponse[]>( const { data } = await apiClient.get<SegmentSummaryResponse[]>(
`/v1/environment/${envApiKey}/identity/${identityId}/segments`, `/v1/environments/${envApiKey}/identities/${identityId}/segments`,
); );
return data; return data;
} }

View File

@@ -7,14 +7,14 @@ import type {
export async function listFeatureStates(envApiKey: string): Promise<FeatureStateResponse[]> { export async function listFeatureStates(envApiKey: string): Promise<FeatureStateResponse[]> {
const { data } = await apiClient.get<FeatureStateResponse[]>( const { data } = await apiClient.get<FeatureStateResponse[]>(
`/v1/environment/${envApiKey}/featurestates`, `/v1/environments/${envApiKey}/featurestates`,
); );
return data; return data;
} }
export async function getFeatureState(envApiKey: string, id: number): Promise<FeatureStateResponse> { export async function getFeatureState(envApiKey: string, id: number): Promise<FeatureStateResponse> {
const { data } = await apiClient.get<FeatureStateResponse>( const { data } = await apiClient.get<FeatureStateResponse>(
`/v1/environment/${envApiKey}/featurestate/${id}`, `/v1/environments/${envApiKey}/featurestates/${id}`,
); );
return data; return data;
} }
@@ -25,7 +25,7 @@ export async function updateFeatureState(
request: UpdateFeatureStateRequest, request: UpdateFeatureStateRequest,
): Promise<FeatureStateResponse> { ): Promise<FeatureStateResponse> {
const { data } = await apiClient.put<FeatureStateResponse>( const { data } = await apiClient.put<FeatureStateResponse>(
`/v1/environment/${envApiKey}/featurestate/${id}`, `/v1/environments/${envApiKey}/featurestates/${id}`,
request, request,
); );
return data; return data;
@@ -37,7 +37,7 @@ export async function patchFeatureState(
request: PatchFeatureStateRequest, request: PatchFeatureStateRequest,
): Promise<FeatureStateResponse> { ): Promise<FeatureStateResponse> {
const { data } = await apiClient.patch<FeatureStateResponse>( const { data } = await apiClient.patch<FeatureStateResponse>(
`/v1/environment/${envApiKey}/featurestate/${id}`, `/v1/environments/${envApiKey}/featurestates/${id}`,
request, request,
); );
return data; return data;

View File

@@ -13,7 +13,7 @@ export async function listFeatures(
pageSize = 100, pageSize = 100,
): Promise<PaginatedResponse<FeatureResponse>> { ): Promise<PaginatedResponse<FeatureResponse>> {
const { data } = await apiClient.get<PaginatedResponse<FeatureResponse>>( const { data } = await apiClient.get<PaginatedResponse<FeatureResponse>>(
`/v1/project/${projectId}/features`, `/v1/projects/${projectId}/features`,
{ params: { page, pageSize } }, { params: { page, pageSize } },
); );
return data; return data;
@@ -21,7 +21,7 @@ export async function listFeatures(
export async function getFeature(projectId: number, id: number): Promise<FeatureResponse> { export async function getFeature(projectId: number, id: number): Promise<FeatureResponse> {
const { data } = await apiClient.get<FeatureResponse>( const { data } = await apiClient.get<FeatureResponse>(
`/v1/project/${projectId}/feature/${id}`, `/v1/projects/${projectId}/features/${id}`,
); );
return data; return data;
} }
@@ -31,7 +31,7 @@ export async function createFeature(
request: CreateFeatureRequest, request: CreateFeatureRequest,
): Promise<FeatureResponse> { ): Promise<FeatureResponse> {
const { data } = await apiClient.post<FeatureResponse>( const { data } = await apiClient.post<FeatureResponse>(
`/v1/project/${projectId}/features`, `/v1/projects/${projectId}/features`,
request, request,
); );
return data; return data;
@@ -43,7 +43,7 @@ export async function updateFeature(
request: UpdateFeatureRequest, request: UpdateFeatureRequest,
): Promise<FeatureResponse> { ): Promise<FeatureResponse> {
const { data } = await apiClient.put<FeatureResponse>( const { data } = await apiClient.put<FeatureResponse>(
`/v1/project/${projectId}/feature/${id}`, `/v1/projects/${projectId}/features/${id}`,
request, request,
); );
return data; return data;
@@ -55,34 +55,12 @@ export async function patchFeature(
request: PatchFeatureRequest, request: PatchFeatureRequest,
): Promise<FeatureResponse> { ): Promise<FeatureResponse> {
const { data } = await apiClient.patch<FeatureResponse>( const { data } = await apiClient.patch<FeatureResponse>(
`/v1/project/${projectId}/feature/${id}`, `/v1/projects/${projectId}/features/${id}`,
request, request,
); );
return data; return data;
} }
export async function deleteFeature(projectId: number, id: number): Promise<void> { export async function deleteFeature(projectId: number, id: number): Promise<void> {
await apiClient.delete(`/v1/project/${projectId}/feature/${id}`); await apiClient.delete(`/v1/projects/${projectId}/features/${id}`);
}
export async function assignFeatureTag(
projectId: number,
featureId: number,
tagId: number,
): Promise<FeatureResponse> {
const { data } = await apiClient.put<FeatureResponse>(
`/v1/project/${projectId}/feature/${featureId}/tag/${tagId}`,
);
return data;
}
export async function removeFeatureTag(
projectId: number,
featureId: number,
tagId: number,
): Promise<FeatureResponse> {
const { data } = await apiClient.delete<FeatureResponse>(
`/v1/project/${projectId}/feature/${featureId}/tag/${tagId}`,
);
return data;
} }

View File

@@ -15,7 +15,7 @@ export async function listIdentities(
pageSize = 100, pageSize = 100,
): Promise<PaginatedResponse<IdentityResponse>> { ): Promise<PaginatedResponse<IdentityResponse>> {
const { data } = await apiClient.get<PaginatedResponse<IdentityResponse>>( const { data } = await apiClient.get<PaginatedResponse<IdentityResponse>>(
`/v1/environment/${envApiKey}/identities`, `/v1/environments/${envApiKey}/identities`,
{ params: { page, pageSize } }, { params: { page, pageSize } },
); );
return data; return data;
@@ -26,7 +26,7 @@ export async function createIdentity(
request: CreateIdentityRequest, request: CreateIdentityRequest,
): Promise<IdentityResponse> { ): Promise<IdentityResponse> {
const { data } = await apiClient.post<IdentityResponse>( const { data } = await apiClient.post<IdentityResponse>(
`/v1/environment/${envApiKey}/identities`, `/v1/environments/${envApiKey}/identities`,
request, request,
); );
return data; return data;
@@ -34,7 +34,7 @@ export async function createIdentity(
export async function getIdentity(envApiKey: string, id: number): Promise<IdentityResponse> { export async function getIdentity(envApiKey: string, id: number): Promise<IdentityResponse> {
const { data } = await apiClient.get<IdentityResponse>( const { data } = await apiClient.get<IdentityResponse>(
`/v1/environment/${envApiKey}/identity/${id}`, `/v1/environments/${envApiKey}/identities/${id}`,
); );
return data; return data;
} }
@@ -46,7 +46,7 @@ export async function upsertIdentityTrait(
request: UpsertTraitRequest, request: UpsertTraitRequest,
): Promise<TraitResponse> { ): Promise<TraitResponse> {
const { data } = await apiClient.put<TraitResponse>( const { data } = await apiClient.put<TraitResponse>(
`/v1/environment/${envApiKey}/identity/${identityId}/trait/${encodeURIComponent(key)}`, `/v1/environments/${envApiKey}/identities/${identityId}/traits/${encodeURIComponent(key)}`,
request, request,
); );
return data; return data;
@@ -58,12 +58,12 @@ export async function deleteIdentityTrait(
key: string, key: string,
): Promise<void> { ): Promise<void> {
await apiClient.delete( await apiClient.delete(
`/v1/environment/${envApiKey}/identity/${identityId}/trait/${encodeURIComponent(key)}`, `/v1/environments/${envApiKey}/identities/${identityId}/traits/${encodeURIComponent(key)}`,
); );
} }
export async function deleteIdentity(envApiKey: string, id: number): Promise<void> { export async function deleteIdentity(envApiKey: string, id: number): Promise<void> {
await apiClient.delete(`/v1/environment/${envApiKey}/identity/${id}`); await apiClient.delete(`/v1/environments/${envApiKey}/identities/${id}`);
} }
export async function getIdentityFeatureStates( export async function getIdentityFeatureStates(
@@ -71,7 +71,7 @@ export async function getIdentityFeatureStates(
identityId: number, identityId: number,
): Promise<FeatureStateResponse[]> { ): Promise<FeatureStateResponse[]> {
const { data } = await apiClient.get<FeatureStateResponse[]>( const { data } = await apiClient.get<FeatureStateResponse[]>(
`/v1/environment/${envApiKey}/identity/${identityId}/featurestates`, `/v1/environments/${envApiKey}/identities/${identityId}/featurestates`,
); );
return data; return data;
} }
@@ -83,7 +83,7 @@ export async function setIdentityFeatureState(
request: UpdateFeatureStateRequest, request: UpdateFeatureStateRequest,
): Promise<FeatureStateResponse> { ): Promise<FeatureStateResponse> {
const { data } = await apiClient.put<FeatureStateResponse>( const { data } = await apiClient.put<FeatureStateResponse>(
`/v1/environment/${envApiKey}/identity/${identityId}/featurestate/${featureId}`, `/v1/environments/${envApiKey}/identities/${identityId}/featurestates/${featureId}`,
request, request,
); );
return data; return data;
@@ -95,6 +95,6 @@ export async function deleteIdentityFeatureState(
featureId: number, featureId: number,
): Promise<void> { ): Promise<void> {
await apiClient.delete( await apiClient.delete(
`/v1/environment/${envApiKey}/identity/${identityId}/featurestate/${featureId}`, `/v1/environments/${envApiKey}/identities/${identityId}/featurestates/${featureId}`,
); );
} }

View File

@@ -20,7 +20,7 @@ export async function listOrganizations(page = 1, pageSize = 100): Promise<Organ
} }
export async function getOrganization(id: number): Promise<OrganizationResponse> { export async function getOrganization(id: number): Promise<OrganizationResponse> {
const { data } = await apiClient.get<OrganizationResponse>(`/v1/organisation/${id}`); const { data } = await apiClient.get<OrganizationResponse>(`/v1/organisations/${id}`);
return data; return data;
} }
@@ -30,27 +30,27 @@ export async function createOrganization(request: CreateOrganizationRequest): Pr
} }
export async function updateOrganization(id: number, request: UpdateOrganizationRequest): Promise<OrganizationResponse> { export async function updateOrganization(id: number, request: UpdateOrganizationRequest): Promise<OrganizationResponse> {
const { data } = await apiClient.put<OrganizationResponse>(`/v1/organisation/${id}`, request); const { data } = await apiClient.put<OrganizationResponse>(`/v1/organisations/${id}`, request);
return data; return data;
} }
export async function deleteOrganization(id: number): Promise<void> { export async function deleteOrganization(id: number): Promise<void> {
await apiClient.delete(`/v1/organisation/${id}`); await apiClient.delete(`/v1/organisations/${id}`);
} }
export async function listOrganizationMembers(organizationId: number): Promise<OrganizationMemberResponse[]> { export async function listOrganizationMembers(organizationId: number): Promise<OrganizationMemberResponse[]> {
const { data } = await apiClient.get<OrganizationMemberResponse[]>( const { data } = await apiClient.get<OrganizationMemberResponse[]>(
`/v1/organisation/${organizationId}/users`, `/v1/organisations/${organizationId}/users`,
); );
return data; return data;
} }
export async function inviteOrganizationMember(organizationId: number, request: InviteUserRequest): Promise<void> { export async function inviteOrganizationMember(organizationId: number, request: InviteUserRequest): Promise<void> {
await apiClient.post(`/v1/organisation/${organizationId}/users/invite`, request); await apiClient.post(`/v1/organisations/${organizationId}/users/invite`, request);
} }
export async function removeOrganizationMember(organizationId: number, userId: number): Promise<void> { export async function removeOrganizationMember(organizationId: number, userId: number): Promise<void> {
await apiClient.delete(`/v1/organisation/${organizationId}/user/${userId}`); await apiClient.delete(`/v1/organisations/${organizationId}/users/${userId}`);
} }
export async function inviteOrganizationMembersByEmail( export async function inviteOrganizationMembersByEmail(
@@ -58,7 +58,7 @@ export async function inviteOrganizationMembersByEmail(
request: InviteUsersByEmailRequest, request: InviteUsersByEmailRequest,
): Promise<InviteByEmailResult[]> { ): Promise<InviteByEmailResult[]> {
const { data } = await apiClient.post<InviteByEmailResult[]>( const { data } = await apiClient.post<InviteByEmailResult[]>(
`/v1/organisation/${organizationId}/users/invite-by-email`, `/v1/organisations/${organizationId}/users/invite-by-email`,
request, request,
); );
return data; return data;
@@ -66,14 +66,14 @@ export async function inviteOrganizationMembersByEmail(
export async function getInviteLink(organizationId: number): Promise<InviteTokenResponse> { export async function getInviteLink(organizationId: number): Promise<InviteTokenResponse> {
const { data } = await apiClient.get<InviteTokenResponse>( const { data } = await apiClient.get<InviteTokenResponse>(
`/v1/organisation/${organizationId}/invite-link`, `/v1/organisations/${organizationId}/invite-link`,
); );
return data; return data;
} }
export async function regenerateInviteLink(organizationId: number): Promise<InviteTokenResponse> { export async function regenerateInviteLink(organizationId: number): Promise<InviteTokenResponse> {
const { data } = await apiClient.post<InviteTokenResponse>( const { data } = await apiClient.post<InviteTokenResponse>(
`/v1/organisation/${organizationId}/invite-link/regenerate`, `/v1/organisations/${organizationId}/invite-link/regenerate`,
); );
return data; return data;
} }
@@ -83,5 +83,5 @@ export async function acceptInvite(token: string): Promise<void> {
} }
export async function setPrimaryOrganization(id: number): Promise<void> { export async function setPrimaryOrganization(id: number): Promise<void> {
await apiClient.put(`/v1/organisation/${id}/primary`); await apiClient.put(`/v1/organisations/${id}/primary`);
} }

View File

@@ -17,7 +17,7 @@ export async function listProjects(organizationId: number, page = 1, pageSize =
} }
export async function getProject(id: number): Promise<ProjectResponse> { export async function getProject(id: number): Promise<ProjectResponse> {
const { data } = await apiClient.get<ProjectResponse>(`/v1/project/${id}`); const { data } = await apiClient.get<ProjectResponse>(`/v1/projects/${id}`);
return data; return data;
} }
@@ -27,17 +27,17 @@ export async function createProject(request: CreateProjectRequest): Promise<Proj
} }
export async function updateProject(id: number, request: UpdateProjectRequest): Promise<ProjectResponse> { export async function updateProject(id: number, request: UpdateProjectRequest): Promise<ProjectResponse> {
const { data } = await apiClient.put<ProjectResponse>(`/v1/project/${id}`, request); const { data } = await apiClient.put<ProjectResponse>(`/v1/projects/${id}`, request);
return data; return data;
} }
export async function deleteProject(id: number): Promise<void> { export async function deleteProject(id: number): Promise<void> {
await apiClient.delete(`/v1/project/${id}`); await apiClient.delete(`/v1/projects/${id}`);
} }
export async function listProjectUserPermissions(projectId: number): Promise<UserPermissionResponse[]> { export async function listProjectUserPermissions(projectId: number): Promise<UserPermissionResponse[]> {
const { data } = await apiClient.get<UserPermissionResponse[]>( const { data } = await apiClient.get<UserPermissionResponse[]>(
`/v1/project/${projectId}/user-permissions`, `/v1/projects/${projectId}/user-permissions`,
); );
return data; return data;
} }
@@ -47,7 +47,7 @@ export async function setProjectUserPermissions(
request: SetUserPermissionsRequest, request: SetUserPermissionsRequest,
): Promise<UserPermissionResponse> { ): Promise<UserPermissionResponse> {
const { data } = await apiClient.post<UserPermissionResponse>( const { data } = await apiClient.post<UserPermissionResponse>(
`/v1/project/${projectId}/user-permissions`, `/v1/projects/${projectId}/user-permissions`,
request, request,
); );
return data; return data;
@@ -59,12 +59,12 @@ export async function updateProjectUserPermissions(
request: SetUserPermissionsRequest, request: SetUserPermissionsRequest,
): Promise<UserPermissionResponse> { ): Promise<UserPermissionResponse> {
const { data } = await apiClient.put<UserPermissionResponse>( const { data } = await apiClient.put<UserPermissionResponse>(
`/v1/project/${projectId}/user-permission/${userId}`, `/v1/projects/${projectId}/user-permissions/${userId}`,
request, request,
); );
return data; return data;
} }
export async function removeProjectUserPermissions(projectId: number, userId: number): Promise<void> { export async function removeProjectUserPermissions(projectId: number, userId: number): Promise<void> {
await apiClient.delete(`/v1/project/${projectId}/user-permission/${userId}`); await apiClient.delete(`/v1/projects/${projectId}/user-permissions/${userId}`);
} }

View File

@@ -8,7 +8,7 @@ import type {
export async function listSegments(projectId: number, page = 1, pageSize = 100): Promise<SegmentResponse[]> { export async function listSegments(projectId: number, page = 1, pageSize = 100): Promise<SegmentResponse[]> {
const { data } = await apiClient.get<PaginatedResponse<SegmentResponse>>( const { data } = await apiClient.get<PaginatedResponse<SegmentResponse>>(
`/v1/project/${projectId}/segments`, `/v1/projects/${projectId}/segments`,
{ params: { page, pageSize } }, { params: { page, pageSize } },
); );
return data.results; return data.results;
@@ -16,7 +16,7 @@ export async function listSegments(projectId: number, page = 1, pageSize = 100):
export async function getSegment(projectId: number, id: number): Promise<SegmentResponse> { export async function getSegment(projectId: number, id: number): Promise<SegmentResponse> {
const { data } = await apiClient.get<SegmentResponse>( const { data } = await apiClient.get<SegmentResponse>(
`/v1/project/${projectId}/segment/${id}`, `/v1/projects/${projectId}/segments/${id}`,
); );
return data; return data;
} }
@@ -26,7 +26,7 @@ export async function createSegment(
request: CreateSegmentRequest, request: CreateSegmentRequest,
): Promise<SegmentResponse> { ): Promise<SegmentResponse> {
const { data } = await apiClient.post<SegmentResponse>( const { data } = await apiClient.post<SegmentResponse>(
`/v1/project/${projectId}/segments`, `/v1/projects/${projectId}/segments`,
request, request,
); );
return data; return data;
@@ -38,12 +38,12 @@ export async function updateSegment(
request: UpdateSegmentRequest, request: UpdateSegmentRequest,
): Promise<SegmentResponse> { ): Promise<SegmentResponse> {
const { data } = await apiClient.put<SegmentResponse>( const { data } = await apiClient.put<SegmentResponse>(
`/v1/project/${projectId}/segment/${id}`, `/v1/projects/${projectId}/segments/${id}`,
request, request,
); );
return data; return data;
} }
export async function deleteSegment(projectId: number, id: number): Promise<void> { export async function deleteSegment(projectId: number, id: number): Promise<void> {
await apiClient.delete(`/v1/project/${projectId}/segment/${id}`); await apiClient.delete(`/v1/projects/${projectId}/segments/${id}`);
} }

View File

@@ -2,15 +2,15 @@ import apiClient from './client';
import type { TagResponse, CreateTagRequest } from '@/types/api'; import type { TagResponse, CreateTagRequest } from '@/types/api';
export async function listTags(projectId: number): Promise<TagResponse[]> { export async function listTags(projectId: number): Promise<TagResponse[]> {
const { data } = await apiClient.get<TagResponse[]>(`/v1/project/${projectId}/tags`); const { data } = await apiClient.get<TagResponse[]>(`/v1/projects/${projectId}/tags`);
return data; return data;
} }
export async function createTag(projectId: number, request: CreateTagRequest): Promise<TagResponse> { export async function createTag(projectId: number, request: CreateTagRequest): Promise<TagResponse> {
const { data } = await apiClient.post<TagResponse>(`/v1/project/${projectId}/tags`, request); const { data } = await apiClient.post<TagResponse>(`/v1/projects/${projectId}/tags`, request);
return data; return data;
} }
export async function deleteTag(projectId: number, id: number): Promise<void> { export async function deleteTag(projectId: number, id: number): Promise<void> {
await apiClient.delete(`/v1/project/${projectId}/tag/${id}`); await apiClient.delete(`/v1/projects/${projectId}/tags/${id}`);
} }

View File

@@ -2,6 +2,6 @@ import apiClient from './client';
import type { DashboardUsageResponse } from '@/types/api'; import type { DashboardUsageResponse } from '@/types/api';
export async function getUsageDashboard(environmentId: number, days = 14): Promise<DashboardUsageResponse> { export async function getUsageDashboard(environmentId: number, days = 14): Promise<DashboardUsageResponse> {
const { data } = await apiClient.get<DashboardUsageResponse>(`/v1/environment/${environmentId}/usage`, { params: { days } }); const { data } = await apiClient.get<DashboardUsageResponse>(`/v1/environments/${environmentId}/usage`, { params: { days } });
return data; return data;
} }

View File

@@ -2,20 +2,20 @@ import apiClient from './client';
import type { UserProfileResponse, UpdateProfileRequest, ChangePasswordRequest } from '@/types/api'; import type { UserProfileResponse, UpdateProfileRequest, ChangePasswordRequest } from '@/types/api';
export async function getMe(): Promise<UserProfileResponse> { export async function getMe(): Promise<UserProfileResponse> {
const { data } = await apiClient.get<UserProfileResponse>('/v1/user/me'); const { data } = await apiClient.get<UserProfileResponse>('/v1/users/me');
return data; return data;
} }
export async function updateMe(request: UpdateProfileRequest): Promise<UserProfileResponse> { export async function updateMe(request: UpdateProfileRequest): Promise<UserProfileResponse> {
const { data } = await apiClient.put<UserProfileResponse>('/v1/user/me', request); const { data } = await apiClient.put<UserProfileResponse>('/v1/users/me', request);
return data; return data;
} }
export async function changePassword(request: ChangePasswordRequest): Promise<void> { export async function changePassword(request: ChangePasswordRequest): Promise<void> {
await apiClient.post('/v1/user/me/change-password', request); await apiClient.post('/v1/users/me/change-password', request);
} }
export async function getUserById(id: number): Promise<UserProfileResponse> { export async function getUserById(id: number): Promise<UserProfileResponse> {
const { data } = await apiClient.get<UserProfileResponse>(`/v1/user/${id}`); const { data } = await apiClient.get<UserProfileResponse>(`/v1/users/${id}`);
return data; return data;
} }

View File

@@ -8,7 +8,7 @@ import type {
// ─── Organization webhooks ──────────────────────────────────────────────────── // ─── Organization webhooks ────────────────────────────────────────────────────
export async function listOrgWebhooks(orgId: number): Promise<WebhookResponse[]> { export async function listOrgWebhooks(orgId: number): Promise<WebhookResponse[]> {
const { data } = await apiClient.get<WebhookResponse[]>(`/v1/organisation/${orgId}/webhooks`); const { data } = await apiClient.get<WebhookResponse[]>(`/v1/organisations/${orgId}/webhooks`);
return data; return data;
} }
@@ -17,7 +17,7 @@ export async function createOrgWebhook(
request: CreateWebhookRequest, request: CreateWebhookRequest,
): Promise<WebhookResponse> { ): Promise<WebhookResponse> {
const { data } = await apiClient.post<WebhookResponse>( const { data } = await apiClient.post<WebhookResponse>(
`/v1/organisation/${orgId}/webhooks`, `/v1/organisations/${orgId}/webhooks`,
request, request,
); );
return data; return data;
@@ -29,21 +29,21 @@ export async function updateOrgWebhook(
request: CreateWebhookRequest, request: CreateWebhookRequest,
): Promise<WebhookResponse> { ): Promise<WebhookResponse> {
const { data } = await apiClient.put<WebhookResponse>( const { data } = await apiClient.put<WebhookResponse>(
`/v1/organisation/${orgId}/webhook/${webhookId}`, `/v1/organisations/${orgId}/webhooks/${webhookId}`,
request, request,
); );
return data; return data;
} }
export async function deleteOrgWebhook(orgId: number, webhookId: number): Promise<void> { export async function deleteOrgWebhook(orgId: number, webhookId: number): Promise<void> {
await apiClient.delete(`/v1/organisation/${orgId}/webhook/${webhookId}`); await apiClient.delete(`/v1/organisations/${orgId}/webhooks/${webhookId}`);
} }
// ─── Environment webhooks ───────────────────────────────────────────────────── // ─── Environment webhooks ─────────────────────────────────────────────────────
export async function listEnvWebhooks(envApiKey: string): Promise<WebhookResponse[]> { export async function listEnvWebhooks(envApiKey: string): Promise<WebhookResponse[]> {
const { data } = await apiClient.get<WebhookResponse[]>( const { data } = await apiClient.get<WebhookResponse[]>(
`/v1/environment/${envApiKey}/webhooks`, `/v1/environments/${envApiKey}/webhooks`,
); );
return data; return data;
} }
@@ -53,7 +53,7 @@ export async function createEnvWebhook(
request: CreateWebhookRequest, request: CreateWebhookRequest,
): Promise<WebhookResponse> { ): Promise<WebhookResponse> {
const { data } = await apiClient.post<WebhookResponse>( const { data } = await apiClient.post<WebhookResponse>(
`/v1/environment/${envApiKey}/webhooks`, `/v1/environments/${envApiKey}/webhooks`,
request, request,
); );
return data; return data;
@@ -65,14 +65,14 @@ export async function updateEnvWebhook(
request: CreateWebhookRequest, request: CreateWebhookRequest,
): Promise<WebhookResponse> { ): Promise<WebhookResponse> {
const { data } = await apiClient.put<WebhookResponse>( const { data } = await apiClient.put<WebhookResponse>(
`/v1/environment/${envApiKey}/webhook/${webhookId}`, `/v1/environments/${envApiKey}/webhooks/${webhookId}`,
request, request,
); );
return data; return data;
} }
export async function deleteEnvWebhook(envApiKey: string, webhookId: number): Promise<void> { export async function deleteEnvWebhook(envApiKey: string, webhookId: number): Promise<void> {
await apiClient.delete(`/v1/environment/${envApiKey}/webhook/${webhookId}`); await apiClient.delete(`/v1/environments/${envApiKey}/webhooks/${webhookId}`);
} }
// ─── Delivery logs ──────────────────────────────────────────────────────────── // ─── Delivery logs ────────────────────────────────────────────────────────────
@@ -82,7 +82,7 @@ export async function listWebhookDeliveries(
webhookId: number, webhookId: number,
): Promise<WebhookDeliveryLogResponse[]> { ): Promise<WebhookDeliveryLogResponse[]> {
const { data } = await apiClient.get<WebhookDeliveryLogResponse[]>( const { data } = await apiClient.get<WebhookDeliveryLogResponse[]>(
`/v1/environment/${envApiKey}/webhook/${webhookId}/deliveries`, `/v1/environments/${envApiKey}/webhooks/${webhookId}/deliveries`,
); );
return data; return data;
} }

View File

@@ -95,7 +95,7 @@
<v-divider class="my-5" /> <v-divider class="my-5" />
<!-- Tags --> <!-- Tags -->
<TagsChipInput v-if="feature" :feature="feature" @updated="onTagsUpdated" /> <TagsChipInput />
</template> </template>
</v-tabs-window-item> </v-tabs-window-item>
@@ -558,10 +558,6 @@ async function onSaveSettings(): Promise<void> {
} }
} }
function onTagsUpdated(updated: FeatureResponse): void {
emit('updated', updated);
}
async function onDelete(): Promise<void> { async function onDelete(): Promise<void> {
if (!props.feature || deleteConfirmName.value !== props.feature.name) return; if (!props.feature || deleteConfirmName.value !== props.feature.name) return;
const projectId = contextStore.currentProject?.id; const projectId = contextStore.currentProject?.id;

View File

@@ -1,7 +1,7 @@
<template> <template>
<div data-testid="tags-chip-input"> <div data-testid="tags-chip-input">
<div class="d-flex align-center justify-space-between mb-2"> <div class="d-flex align-center justify-space-between mb-2">
<span class="text-body-2 font-weight-medium">Tags</span> <span class="text-body-2 font-weight-medium">Project Tags</span>
<v-btn <v-btn
size="x-small" size="x-small"
variant="tonal" variant="tonal"
@@ -71,8 +71,6 @@
</v-card> </v-card>
</v-expand-transition> </v-expand-transition>
<p class="text-caption text-medium-emphasis mb-2">Click a tag to add or remove it from this feature.</p>
<!-- Tag chips --> <!-- Tag chips -->
<div v-if="isLoading" class="d-flex justify-center py-3"> <div v-if="isLoading" class="d-flex justify-center py-3">
<v-progress-circular indeterminate size="20" width="2" color="primary" /> <v-progress-circular indeterminate size="20" width="2" color="primary" />
@@ -84,12 +82,8 @@
:key="tag.id" :key="tag.id"
size="small" size="small"
closable closable
:variant="isAssigned(tag) ? 'flat' : 'outlined'"
:color="tag.color" :color="tag.color"
:prepend-icon="isAssigned(tag) ? 'ri-check-line' : undefined"
:loading="togglingTagId === tag.id"
:data-testid="`tag-chip-${tag.label}`" :data-testid="`tag-chip-${tag.label}`"
@click="onToggleTag(tag)"
@click:close="onDeleteTag(tag)" @click:close="onDeleteTag(tag)"
> >
{{ tag.label }} {{ tag.label }}
@@ -109,26 +103,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, watch, nextTick } from 'vue'; import { ref, onMounted, watch, nextTick } from 'vue';
import { listTags, createTag, deleteTag } from '@/api/tags'; import { listTags, createTag, deleteTag } from '@/api/tags';
import { assignFeatureTag, removeFeatureTag } from '@/api/features';
import { useContextStore } from '@/stores/context'; import { useContextStore } from '@/stores/context';
import type { TagResponse, FeatureResponse } from '@/types/api'; import type { TagResponse } from '@/types/api';
interface Props {
feature: FeatureResponse;
}
const props = defineProps<Props>();
const emit = defineEmits<{
updated: [feature: FeatureResponse];
}>();
const contextStore = useContextStore(); const contextStore = useContextStore();
const tags = ref<TagResponse[]>([]); const tags = ref<TagResponse[]>([]);
const isLoading = ref(false); const isLoading = ref(false);
const isCreating = ref(false); const isCreating = ref(false);
const togglingTagId = ref<number | null>(null);
const showCreateForm = ref(false); const showCreateForm = ref(false);
const newTagLabel = ref(''); const newTagLabel = ref('');
const newTagColor = ref('#1565C0'); const newTagColor = ref('#1565C0');
@@ -136,10 +118,6 @@ const errorMessage = ref<string | null>(null);
const createFormRef = ref<{ validate: () => Promise<{ valid: boolean }> } | null>(null); const createFormRef = ref<{ validate: () => Promise<{ valid: boolean }> } | null>(null);
const tagLabelInputRef = ref<{ focus: () => void } | null>(null); const tagLabelInputRef = ref<{ focus: () => void } | null>(null);
function isAssigned(tag: TagResponse): boolean {
return props.feature.tags.some((t) => t.id === tag.id);
}
function focusTagLabel(): void { function focusTagLabel(): void {
nextTick(() => tagLabelInputRef.value?.focus()); nextTick(() => tagLabelInputRef.value?.focus());
} }
@@ -192,24 +170,6 @@ async function onCreateTag(): Promise<void> {
} }
} }
async function onToggleTag(tag: TagResponse): Promise<void> {
const projectId = contextStore.currentProject?.id;
if (!projectId) return;
togglingTagId.value = tag.id;
errorMessage.value = null;
try {
const updated = isAssigned(tag)
? await removeFeatureTag(projectId, props.feature.id, tag.id)
: await assignFeatureTag(projectId, props.feature.id, tag.id);
emit('updated', updated);
} catch {
errorMessage.value = 'Failed to update tag assignment. Please try again.';
} finally {
togglingTagId.value = null;
}
}
async function onDeleteTag(tag: TagResponse): Promise<void> { async function onDeleteTag(tag: TagResponse): Promise<void> {
const projectId = contextStore.currentProject?.id; const projectId = contextStore.currentProject?.id;
if (!projectId) return; if (!projectId) return;

View File

@@ -1,164 +0,0 @@
<template>
<div data-testid="org-header-switcher">
<v-menu data-testid="org-header-menu">
<template #activator="{ props: menuProps }">
<a
href="#"
class="org-header-link text-body-2 font-weight-medium d-flex align-center"
data-testid="org-header-link"
v-bind="menuProps"
@click.prevent
>
<VIcon icon="ri-building-line" size="18" class="me-1" />
{{ contextStore.currentOrganization?.name ?? 'Select organization' }}
<VIcon icon="ri-arrow-down-s-line" size="18" />
</a>
</template>
<v-list density="compact" data-testid="org-header-list">
<v-list-item
v-for="org in organizations"
:key="org.id"
:title="org.name"
:active="org.id === contextStore.currentOrganization?.id"
:data-testid="`org-header-item-${org.id}`"
@click="onOrgSelected(org)"
/>
<v-divider v-if="organizations.length" class="my-1" />
<v-list-item
title="New Organization"
prepend-icon="ri-add-line"
data-testid="org-header-create-item"
@click="openCreateDialog"
/>
</v-list>
</v-menu>
<!-- Create Organization Dialog -->
<v-dialog v-model="showDialog" max-width="420" data-testid="create-org-dialog">
<v-card rounded="lg">
<v-card-title class="pa-5 pb-3">Create organization</v-card-title>
<v-card-text class="pa-5 pt-0">
<v-text-field
v-model="newOrgName"
label="Organization name"
variant="outlined"
density="compact"
autofocus
:error-messages="createError ? [createError] : []"
data-testid="org-name-input"
@keydown.enter="onCreateConfirm"
/>
</v-card-text>
<v-card-actions class="pa-5 pt-0">
<v-spacer />
<v-btn variant="text" data-testid="create-org-cancel-btn" @click="closeDialog">Cancel</v-btn>
<v-btn
color="primary"
variant="flat"
:loading="isSaving"
:disabled="!newOrgName.trim()"
data-testid="create-org-confirm-btn"
@click="onCreateConfirm"
>
Create
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useContextStore } from '@/stores/context';
import { listOrganizations, createOrganization, setPrimaryOrganization } from '@/api/organizations';
import type { OrganizationResponse } from '@/types/api';
const contextStore = useContextStore();
const organizations = ref<OrganizationResponse[]>([]);
const showDialog = ref(false);
const newOrgName = ref('');
const isSaving = ref(false);
const createError = ref('');
async function fetchOrganizations(): Promise<void> {
try {
organizations.value = await listOrganizations();
autoSelectOrganization();
} catch {
organizations.value = [];
}
}
function autoSelectOrganization(): void {
if (contextStore.currentOrganization) {
// Refresh stored org with latest data from API (name may have changed)
const fresh = organizations.value.find(o => o.id === contextStore.currentOrganization!.id);
if (fresh) contextStore.refreshOrganization(fresh);
return;
}
const primary = organizations.value.find(o => o.isPrimary);
if (primary) {
contextStore.setOrganization(primary);
return;
}
if (organizations.value.length === 1) {
contextStore.setOrganization(organizations.value[0]);
}
}
async function onOrgSelected(org: OrganizationResponse): Promise<void> {
contextStore.setOrganization(org);
try {
await setPrimaryOrganization(org.id);
organizations.value = organizations.value.map(o => ({ ...o, isPrimary: o.id === org.id }));
} catch {
// Non-critical — selection is already saved to localStorage
}
}
function openCreateDialog(): void {
newOrgName.value = '';
createError.value = '';
showDialog.value = true;
}
function closeDialog(): void {
showDialog.value = false;
}
async function onCreateConfirm(): Promise<void> {
const name = newOrgName.value.trim();
if (!name) return;
isSaving.value = true;
createError.value = '';
try {
const created = await createOrganization({ name });
organizations.value = [...organizations.value, created];
contextStore.setOrganization(created);
closeDialog();
} catch {
createError.value = 'Failed to create organization. Please try again.';
} finally {
isSaving.value = false;
}
}
onMounted(fetchOrganizations);
</script>
<style lang="scss" scoped>
.org-header-link {
text-decoration: none;
color: inherit;
white-space: nowrap;
}
</style>

View File

@@ -3,7 +3,6 @@ import NavItems from '@/layouts/components/NavItems.vue';
import Footer from '@/layouts/components/Footer.vue'; import Footer from '@/layouts/components/Footer.vue';
import NavbarThemeSwitcher from '@/layouts/components/NavbarThemeSwitcher.vue'; import NavbarThemeSwitcher from '@/layouts/components/NavbarThemeSwitcher.vue';
import UserProfile from '@/layouts/components/UserProfile.vue'; import UserProfile from '@/layouts/components/UserProfile.vue';
import OrgHeaderSwitcher from '@/components/nav/OrgHeaderSwitcher.vue';
import VerticalNavLayout from '@layouts/components/VerticalNavLayout.vue'; import VerticalNavLayout from '@layouts/components/VerticalNavLayout.vue';
</script> </script>
@@ -17,7 +16,6 @@ import VerticalNavLayout from '@layouts/components/VerticalNavLayout.vue';
<VSpacer /> <VSpacer />
<OrgHeaderSwitcher class="me-4" />
<NavbarThemeSwitcher class="me-1" /> <NavbarThemeSwitcher class="me-1" />
<UserProfile /> <UserProfile />
</div> </div>

View File

@@ -1,6 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import VerticalNavLink from '@layouts/components/VerticalNavLink.vue'; import VerticalNavLink from '@layouts/components/VerticalNavLink.vue';
import VerticalNavSectionTitle from '@layouts/components/VerticalNavSectionTitle.vue'; import VerticalNavSectionTitle from '@layouts/components/VerticalNavSectionTitle.vue';
import OrgSelector from '@/components/nav/OrgSelector.vue';
import ProjectSelector from '@/components/nav/ProjectSelector.vue'; import ProjectSelector from '@/components/nav/ProjectSelector.vue';
import EnvironmentTabBar from '@/components/nav/EnvironmentTabBar.vue'; import EnvironmentTabBar from '@/components/nav/EnvironmentTabBar.vue';
</script> </script>
@@ -14,6 +15,7 @@ import EnvironmentTabBar from '@/components/nav/EnvironmentTabBar.vue';
<VerticalNavSectionTitle :item="{ heading: 'Context' }" /> <VerticalNavSectionTitle :item="{ heading: 'Context' }" />
<div class="nav-context-selectors px-4 d-flex flex-column ga-3"> <div class="nav-context-selectors px-4 d-flex flex-column ga-3">
<OrgSelector />
<ProjectSelector /> <ProjectSelector />
<EnvironmentTabBar /> <EnvironmentTabBar />
</div> </div>

View File

@@ -4,7 +4,6 @@ import VueApexCharts from 'vue3-apexcharts';
import App from './App.vue'; import App from './App.vue';
// Styles // Styles
import '@fontsource-variable/inter';
import '@core/scss/template/index.scss'; import '@core/scss/template/index.scss';
import '@layouts/styles/index.scss'; import '@layouts/styles/index.scss';
import router from './router'; import router from './router';

View File

@@ -215,7 +215,6 @@ export interface FeatureResponse {
defaultEnabled: boolean; defaultEnabled: boolean;
projectId: number; projectId: number;
createdAt: string; createdAt: string;
tags: TagResponse[];
} }
export interface CreateFeatureRequest { export interface CreateFeatureRequest {

View File

@@ -53,7 +53,7 @@
<!-- Name + description --> <!-- Name + description -->
<template #item.name="{ item }: { item: FeatureResponse }"> <template #item.name="{ item }: { item: FeatureResponse }">
<div> <div>
<div class="d-flex flex-wrap align-center ga-1"> <div class="d-flex align-center ga-1">
<span class="text-subtitle-1 font-weight-bold">{{ item.name }}</span> <span class="text-subtitle-1 font-weight-bold">{{ item.name }}</span>
<v-tooltip <v-tooltip
v-if="(featureSegmentsMap.get(item.id) ?? []).length > 0" v-if="(featureSegmentsMap.get(item.id) ?? []).length > 0"
@@ -72,21 +72,6 @@
/> />
</template> </template>
</v-tooltip> </v-tooltip>
<v-chip
v-for="tag in item.tags.slice(0, 5)"
:key="tag.id"
size="x-small"
variant="flat"
:color="tag.color"
:data-testid="`feature-tag-chip-${item.id}-${tag.id}`"
>
{{ tag.label }}
</v-chip>
<span
v-if="item.tags.length > 5"
class="text-caption text-medium-emphasis"
:title="`${item.tags.length - 5} more`"
></span>
</div> </div>
<p v-if="item.description" class="text-caption text-medium-emphasis mb-0"> <p v-if="item.description" class="text-caption text-medium-emphasis mb-0">
{{ item.description }} {{ item.description }}

View File

@@ -18,7 +18,7 @@ public class AuditLogsController(
ProjectService projectService, ProjectService projectService,
EnvironmentService environmentService) : ControllerBase EnvironmentService environmentService) : ControllerBase
{ {
[HttpGet("api/v1/organisation/{id}/audit-logs")] [HttpGet("api/v1/organisations/{id}/audit-logs")]
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByOrganization( public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByOrganization(
int id, int id,
[FromQuery] AuditLogFilter filter, [FromQuery] AuditLogFilter filter,
@@ -31,7 +31,7 @@ public class AuditLogsController(
return Ok(new PaginatedResponse<AuditLogResponse>(total, null, null, logs.ToList())); return Ok(new PaginatedResponse<AuditLogResponse>(total, null, null, logs.ToList()));
} }
[HttpGet("api/v1/project/{projectId}/audit-logs")] [HttpGet("api/v1/projects/{projectId}/audit-logs")]
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByProject( public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByProject(
int projectId, int projectId,
[FromQuery] AuditLogFilter filter, [FromQuery] AuditLogFilter filter,
@@ -44,7 +44,7 @@ public class AuditLogsController(
return Ok(new PaginatedResponse<AuditLogResponse>(total, null, null, logs.ToList())); return Ok(new PaginatedResponse<AuditLogResponse>(total, null, null, logs.ToList()));
} }
[HttpGet("api/v1/environment/{apiKey}/audit-logs")] [HttpGet("api/v1/environments/{apiKey}/audit-logs")]
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByEnvironment( public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByEnvironment(
string apiKey, string apiKey,
[FromQuery] AuditLogFilter filter, [FromQuery] AuditLogFilter filter,

View File

@@ -7,11 +7,11 @@ public static class ApiKeyEndpoints
public static void MapApiKeyEndpoints(this WebApplication app) public static void MapApiKeyEndpoints(this WebApplication app)
{ {
var group = app var group = app
.MapGroup("/api/v1/organisation/{organizationId:int}") .MapGroup("/api/v1/organisations/{organizationId:int}/api-keys")
.RequireAuthorization(AuthorizationPolicies.OrganizationAdmin) .RequireAuthorization(AuthorizationPolicies.OrganizationAdmin)
.WithTags("ApiKeys"); .WithTags("ApiKeys");
group.MapPost("/api-keys", async (int organizationId, CreateApiKeyRequest request, ApiKeyService apiKeyService, CancellationToken ct) => group.MapPost("/", async (int organizationId, CreateApiKeyRequest request, ApiKeyService apiKeyService, CancellationToken ct) =>
{ {
var (key, rawKey) = await apiKeyService.CreateAsync( var (key, rawKey) = await apiKeyService.CreateAsync(
organizationId, request.Name, request.ExpiresAt, ct); organizationId, request.Name, request.ExpiresAt, ct);
@@ -20,14 +20,14 @@ public static class ApiKeyEndpoints
}).WithName("CreateApiKey"); }).WithName("CreateApiKey");
group.MapGet("/api-keys", async (int organizationId, ApiKeyService apiKeyService, CancellationToken ct) => group.MapGet("/", async (int organizationId, ApiKeyService apiKeyService, CancellationToken ct) =>
{ {
var keys = await apiKeyService.ListAsync(organizationId, ct); var keys = await apiKeyService.ListAsync(organizationId, ct);
return Results.Ok(keys.Select(k => new ApiKeyResponse(k.Id, k.Name, k.Prefix, k.IsActive, k.ExpiresAt, k.CreatedAt))); return Results.Ok(keys.Select(k => new ApiKeyResponse(k.Id, k.Name, k.Prefix, k.IsActive, k.ExpiresAt, k.CreatedAt)));
}).WithName("ListApiKeys"); }).WithName("ListApiKeys");
group.MapDelete("/api-key/{keyId:int}", async (int organizationId, int keyId, ApiKeyService apiKeyService, CancellationToken ct) => group.MapDelete("/{keyId:int}", async (int organizationId, int keyId, ApiKeyService apiKeyService, CancellationToken ct) =>
{ {
await apiKeyService.RevokeAsync(organizationId, keyId, ct); await apiKeyService.RevokeAsync(organizationId, keyId, ct);
return Results.NoContent(); return Results.NoContent();

View File

@@ -8,11 +8,12 @@ using Microsoft.AspNetCore.RateLimiting;
namespace MicCheck.Api.Environments; namespace MicCheck.Api.Environments;
[ApiController] [ApiController]
[Route("api/v1/environments")]
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)] [Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
[EnableRateLimiting("AdminApi")] [EnableRateLimiting("AdminApi")]
public class EnvironmentsController(EnvironmentService environmentService, WebhookService webhookService) : ControllerBase public class EnvironmentsController(EnvironmentService environmentService, WebhookService webhookService) : ControllerBase
{ {
[HttpGet("api/v1/environments")] [HttpGet]
public async Task<ActionResult<PaginatedResponse<EnvironmentResponse>>> List( public async Task<ActionResult<PaginatedResponse<EnvironmentResponse>>> List(
[FromQuery] int projectId, [FromQuery] int projectId,
[FromQuery] int page = 1, [FromQuery] int page = 1,
@@ -25,14 +26,14 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
return Ok(new PaginatedResponse<EnvironmentResponse>(all.Count, null, null, paged)); return Ok(new PaginatedResponse<EnvironmentResponse>(all.Count, null, null, paged));
} }
[HttpPost("api/v1/environments")] [HttpPost]
public async Task<ActionResult<EnvironmentResponse>> Create(CreateEnvironmentRequest request, CancellationToken ct) public async Task<ActionResult<EnvironmentResponse>> Create(CreateEnvironmentRequest request, CancellationToken ct)
{ {
var environment = await environmentService.CreateAsync(request.ProjectId, request.Name, ct); var environment = await environmentService.CreateAsync(request.ProjectId, request.Name, ct);
return CreatedAtAction(nameof(GetByApiKey), new { apiKey = environment.ApiKey }, EnvironmentResponse.From(environment)); return CreatedAtAction(nameof(GetByApiKey), new { apiKey = environment.ApiKey }, EnvironmentResponse.From(environment));
} }
[HttpGet("api/v1/environment/{apiKey}")] [HttpGet("{apiKey}")]
public async Task<ActionResult<EnvironmentResponse>> GetByApiKey(string apiKey, CancellationToken ct) public async Task<ActionResult<EnvironmentResponse>> GetByApiKey(string apiKey, CancellationToken ct)
{ {
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -40,7 +41,7 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
return Ok(EnvironmentResponse.From(environment)); return Ok(EnvironmentResponse.From(environment));
} }
[HttpPut("api/v1/environment/{apiKey}")] [HttpPut("{apiKey}")]
public async Task<ActionResult<EnvironmentResponse>> Update(string apiKey, UpdateEnvironmentRequest request, CancellationToken ct) public async Task<ActionResult<EnvironmentResponse>> Update(string apiKey, UpdateEnvironmentRequest request, CancellationToken ct)
{ {
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -50,7 +51,7 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
return Ok(EnvironmentResponse.From(updated)); return Ok(EnvironmentResponse.From(updated));
} }
[HttpDelete("api/v1/environment/{apiKey}")] [HttpDelete("{apiKey}")]
public async Task<IActionResult> Delete(string apiKey, CancellationToken ct) public async Task<IActionResult> Delete(string apiKey, CancellationToken ct)
{ {
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -60,7 +61,7 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
return NoContent(); return NoContent();
} }
[HttpPost("api/v1/environment/{apiKey}/clone")] [HttpPost("{apiKey}/clone")]
public async Task<ActionResult<EnvironmentResponse>> Clone(string apiKey, CloneEnvironmentRequest request, CancellationToken ct) public async Task<ActionResult<EnvironmentResponse>> Clone(string apiKey, CloneEnvironmentRequest request, CancellationToken ct)
{ {
var source = await environmentService.FindByApiKeyAsync(apiKey, ct); var source = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -70,7 +71,7 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
return CreatedAtAction(nameof(GetByApiKey), new { apiKey = cloned.ApiKey }, EnvironmentResponse.From(cloned)); return CreatedAtAction(nameof(GetByApiKey), new { apiKey = cloned.ApiKey }, EnvironmentResponse.From(cloned));
} }
[HttpGet("api/v1/environment/{apiKey}/webhooks")] [HttpGet("{apiKey}/webhooks")]
public async Task<ActionResult<IReadOnlyList<WebhookResponse>>> ListWebhooks(string apiKey, CancellationToken ct) public async Task<ActionResult<IReadOnlyList<WebhookResponse>>> ListWebhooks(string apiKey, CancellationToken ct)
{ {
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -80,7 +81,7 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
return Ok(webhooks.Select(WebhookResponse.From).ToList()); return Ok(webhooks.Select(WebhookResponse.From).ToList());
} }
[HttpPost("api/v1/environment/{apiKey}/webhooks")] [HttpPost("{apiKey}/webhooks")]
public async Task<ActionResult<WebhookResponse>> CreateWebhook( public async Task<ActionResult<WebhookResponse>> CreateWebhook(
string apiKey, CreateWebhookRequest request, CancellationToken ct) string apiKey, CreateWebhookRequest request, CancellationToken ct)
{ {
@@ -91,7 +92,7 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
return CreatedAtAction(nameof(GetByApiKey), new { apiKey }, WebhookResponse.From(webhook)); return CreatedAtAction(nameof(GetByApiKey), new { apiKey }, WebhookResponse.From(webhook));
} }
[HttpPut("api/v1/environment/{apiKey}/webhook/{id}")] [HttpPut("{apiKey}/webhooks/{id}")]
public async Task<ActionResult<WebhookResponse>> UpdateWebhook( public async Task<ActionResult<WebhookResponse>> UpdateWebhook(
string apiKey, int id, CreateWebhookRequest request, CancellationToken ct) string apiKey, int id, CreateWebhookRequest request, CancellationToken ct)
{ {
@@ -105,7 +106,7 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
return Ok(WebhookResponse.From(updated)); return Ok(WebhookResponse.From(updated));
} }
[HttpGet("api/v1/environment/{apiKey}/webhook/{id}/deliveries")] [HttpGet("{apiKey}/webhooks/{id}/deliveries")]
public async Task<ActionResult<IReadOnlyList<WebhookDeliveryLogResponse>>> ListWebhookDeliveries( public async Task<ActionResult<IReadOnlyList<WebhookDeliveryLogResponse>>> ListWebhookDeliveries(
string apiKey, int id, CancellationToken ct) string apiKey, int id, CancellationToken ct)
{ {
@@ -119,7 +120,7 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
return Ok(logs.Select(WebhookDeliveryLogResponse.From).ToList()); return Ok(logs.Select(WebhookDeliveryLogResponse.From).ToList());
} }
[HttpDelete("api/v1/environment/{apiKey}/webhook/{id}")] [HttpDelete("{apiKey}/webhooks/{id}")]
public async Task<IActionResult> DeleteWebhook(string apiKey, int id, CancellationToken ct) public async Task<IActionResult> DeleteWebhook(string apiKey, int id, CancellationToken ct)
{ {
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct); var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
@@ -132,7 +133,7 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
return NoContent(); return NoContent();
} }
[HttpGet("api/v1/environment/{apiKey}/audit-logs")] [HttpGet("{apiKey}/audit-logs")]
public async Task<ActionResult<IReadOnlyList<Audit.AuditLogResponse>>> ListAuditLogs( public async Task<ActionResult<IReadOnlyList<Audit.AuditLogResponse>>> ListAuditLogs(
string apiKey, string apiKey,
[FromServices] Audit.AuditLogQueryService auditLogQueryService, [FromServices] Audit.AuditLogQueryService auditLogQueryService,
@@ -145,7 +146,7 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
return Ok(logs.ToList()); return Ok(logs.ToList());
} }
[HttpGet("api/v1/environment/{apiKey}/identities")] [HttpGet("{apiKey}/identities")]
public async Task<ActionResult<PaginatedResponse<Identities.AdminIdentityResponse>>> ListIdentities( public async Task<ActionResult<PaginatedResponse<Identities.AdminIdentityResponse>>> ListIdentities(
string apiKey, string apiKey,
[FromServices] Identities.AdminIdentityService adminIdentityService, [FromServices] Identities.AdminIdentityService adminIdentityService,
@@ -162,7 +163,7 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
return Ok(new PaginatedResponse<Identities.AdminIdentityResponse>(total, null, null, results)); return Ok(new PaginatedResponse<Identities.AdminIdentityResponse>(total, null, null, results));
} }
[HttpPost("api/v1/environment/{apiKey}/identities")] [HttpPost("{apiKey}/identities")]
public async Task<ActionResult<Identities.AdminIdentityResponse>> CreateIdentity( public async Task<ActionResult<Identities.AdminIdentityResponse>> CreateIdentity(
string apiKey, string apiKey,
[FromBody] Identities.CreateIdentityRequest request, [FromBody] Identities.CreateIdentityRequest request,
@@ -180,7 +181,7 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
Identities.AdminIdentityResponse.From(identity)); Identities.AdminIdentityResponse.From(identity));
} }
[HttpGet("api/v1/environment/{apiKey}/identity/{id}")] [HttpGet("{apiKey}/identities/{id}")]
public async Task<ActionResult<Identities.AdminIdentityResponse>> GetIdentity( public async Task<ActionResult<Identities.AdminIdentityResponse>> GetIdentity(
string apiKey, int id, string apiKey, int id,
[FromServices] Identities.AdminIdentityService adminIdentityService, [FromServices] Identities.AdminIdentityService adminIdentityService,
@@ -194,7 +195,7 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
return Ok(Identities.AdminIdentityResponse.From(identity)); return Ok(Identities.AdminIdentityResponse.From(identity));
} }
[HttpDelete("api/v1/environment/{apiKey}/identity/{id}")] [HttpDelete("{apiKey}/identities/{id}")]
public async Task<IActionResult> DeleteIdentity( public async Task<IActionResult> DeleteIdentity(
string apiKey, int id, string apiKey, int id,
[FromServices] Identities.AdminIdentityService adminIdentityService, [FromServices] Identities.AdminIdentityService adminIdentityService,
@@ -210,7 +211,7 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
return NoContent(); return NoContent();
} }
[HttpPut("api/v1/environment/{apiKey}/identity/{id}/trait/{key}")] [HttpPut("{apiKey}/identities/{id}/traits/{key}")]
public async Task<ActionResult<Identities.TraitResponse>> UpsertIdentityTrait( public async Task<ActionResult<Identities.TraitResponse>> UpsertIdentityTrait(
string apiKey, int id, string key, string apiKey, int id, string key,
[FromBody] Identities.UpsertTraitRequest request, [FromBody] Identities.UpsertTraitRequest request,
@@ -225,7 +226,7 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
return Ok(result); return Ok(result);
} }
[HttpDelete("api/v1/environment/{apiKey}/identity/{id}/trait/{key}")] [HttpDelete("{apiKey}/identities/{id}/traits/{key}")]
public async Task<IActionResult> DeleteIdentityTrait( public async Task<IActionResult> DeleteIdentityTrait(
string apiKey, int id, string key, string apiKey, int id, string key,
[FromServices] Identities.AdminIdentityService adminIdentityService, [FromServices] Identities.AdminIdentityService adminIdentityService,
@@ -239,7 +240,7 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
return NoContent(); return NoContent();
} }
[HttpGet("api/v1/environment/{apiKey}/identity/{id}/featurestates")] [HttpGet("{apiKey}/identities/{id}/featurestates")]
public async Task<ActionResult<IReadOnlyList<Features.FeatureStateResponse>>> GetIdentityFeatureStates( public async Task<ActionResult<IReadOnlyList<Features.FeatureStateResponse>>> GetIdentityFeatureStates(
string apiKey, int id, string apiKey, int id,
[FromServices] Identities.AdminIdentityService adminIdentityService, [FromServices] Identities.AdminIdentityService adminIdentityService,
@@ -255,7 +256,7 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
return Ok(states.Select(Features.FeatureStateResponse.From).ToList()); return Ok(states.Select(Features.FeatureStateResponse.From).ToList());
} }
[HttpPut("api/v1/environment/{apiKey}/identity/{id}/featurestate/{featureId}")] [HttpPut("{apiKey}/identities/{id}/featurestates/{featureId}")]
public async Task<ActionResult<Features.FeatureStateResponse>> SetIdentityFeatureState( public async Task<ActionResult<Features.FeatureStateResponse>> SetIdentityFeatureState(
string apiKey, int id, int featureId, string apiKey, int id, int featureId,
Features.UpdateFeatureStateRequest request, Features.UpdateFeatureStateRequest request,
@@ -272,7 +273,7 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
return Ok(Features.FeatureStateResponse.From(state)); return Ok(Features.FeatureStateResponse.From(state));
} }
[HttpDelete("api/v1/environment/{apiKey}/identity/{id}/featurestate/{featureId}")] [HttpDelete("{apiKey}/identities/{id}/featurestates/{featureId}")]
public async Task<IActionResult> DeleteIdentityFeatureState( public async Task<IActionResult> DeleteIdentityFeatureState(
string apiKey, int id, int featureId, string apiKey, int id, int featureId,
[FromServices] Identities.AdminIdentityService adminIdentityService, [FromServices] Identities.AdminIdentityService adminIdentityService,
@@ -288,7 +289,7 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
return NoContent(); return NoContent();
} }
[HttpGet("api/v1/environment/{apiKey}/featurestates")] [HttpGet("{apiKey}/featurestates")]
public async Task<ActionResult<IReadOnlyList<Features.FeatureStateResponse>>> ListFeatureStates( public async Task<ActionResult<IReadOnlyList<Features.FeatureStateResponse>>> ListFeatureStates(
string apiKey, string apiKey,
[FromServices] Features.FeatureStateService featureStateService, [FromServices] Features.FeatureStateService featureStateService,
@@ -301,7 +302,7 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
return Ok(states.Select(Features.FeatureStateResponse.From).ToList()); return Ok(states.Select(Features.FeatureStateResponse.From).ToList());
} }
[HttpGet("api/v1/environment/{apiKey}/featurestate/{id}")] [HttpGet("{apiKey}/featurestates/{id}")]
public async Task<ActionResult<Features.FeatureStateResponse>> GetFeatureState( public async Task<ActionResult<Features.FeatureStateResponse>> GetFeatureState(
string apiKey, int id, string apiKey, int id,
[FromServices] Features.FeatureStateService featureStateService, [FromServices] Features.FeatureStateService featureStateService,
@@ -315,7 +316,7 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
return Ok(Features.FeatureStateResponse.From(state)); return Ok(Features.FeatureStateResponse.From(state));
} }
[HttpPut("api/v1/environment/{apiKey}/featurestate/{id}")] [HttpPut("{apiKey}/featurestates/{id}")]
public async Task<ActionResult<Features.FeatureStateResponse>> UpdateFeatureState( public async Task<ActionResult<Features.FeatureStateResponse>> UpdateFeatureState(
string apiKey, int id, string apiKey, int id,
Features.UpdateFeatureStateRequest request, Features.UpdateFeatureStateRequest request,
@@ -332,7 +333,7 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
return Ok(Features.FeatureStateResponse.From(updated)); return Ok(Features.FeatureStateResponse.From(updated));
} }
[HttpPatch("api/v1/environment/{apiKey}/featurestate/{id}")] [HttpPatch("{apiKey}/featurestates/{id}")]
public async Task<ActionResult<Features.FeatureStateResponse>> PatchFeatureState( public async Task<ActionResult<Features.FeatureStateResponse>> PatchFeatureState(
string apiKey, int id, string apiKey, int id,
Features.PatchFeatureStateRequest request, Features.PatchFeatureStateRequest request,
@@ -351,7 +352,7 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
// ─── Feature Segments ──────────────────────────────────────────────────── // ─── Feature Segments ────────────────────────────────────────────────────
[HttpGet("api/v1/environment/{apiKey}/feature/{featureId}/segments")] [HttpGet("{apiKey}/features/{featureId}/segments")]
public async Task<ActionResult<IReadOnlyList<Features.FeatureSegmentResponse>>> ListFeatureSegments( public async Task<ActionResult<IReadOnlyList<Features.FeatureSegmentResponse>>> ListFeatureSegments(
string apiKey, int featureId, string apiKey, int featureId,
[FromServices] Features.FeatureSegmentService featureSegmentService, [FromServices] Features.FeatureSegmentService featureSegmentService,
@@ -364,7 +365,7 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
return Ok(results); return Ok(results);
} }
[HttpPost("api/v1/environment/{apiKey}/feature/{featureId}/segments")] [HttpPost("{apiKey}/features/{featureId}/segments")]
public async Task<ActionResult<Features.FeatureSegmentResponse>> CreateFeatureSegment( public async Task<ActionResult<Features.FeatureSegmentResponse>> CreateFeatureSegment(
string apiKey, int featureId, string apiKey, int featureId,
Features.CreateFeatureSegmentRequest request, Features.CreateFeatureSegmentRequest request,
@@ -387,7 +388,7 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
} }
} }
[HttpPut("api/v1/environment/{apiKey}/feature/{featureId}/segment/{id}")] [HttpPut("{apiKey}/features/{featureId}/segments/{id}")]
public async Task<ActionResult<Features.FeatureSegmentResponse>> UpdateFeatureSegment( public async Task<ActionResult<Features.FeatureSegmentResponse>> UpdateFeatureSegment(
string apiKey, int featureId, int id, string apiKey, int featureId, int id,
Features.UpdateFeatureSegmentRequest request, Features.UpdateFeatureSegmentRequest request,
@@ -402,7 +403,7 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
return Ok(result); return Ok(result);
} }
[HttpDelete("api/v1/environment/{apiKey}/feature/{featureId}/segment/{id}")] [HttpDelete("{apiKey}/features/{featureId}/segments/{id}")]
public async Task<IActionResult> DeleteFeatureSegment( public async Task<IActionResult> DeleteFeatureSegment(
string apiKey, int featureId, int id, string apiKey, int featureId, int id,
[FromServices] Features.FeatureSegmentService featureSegmentService, [FromServices] Features.FeatureSegmentService featureSegmentService,
@@ -417,7 +418,7 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
// ─── Identity Segments ─────────────────────────────────────────────────── // ─── Identity Segments ───────────────────────────────────────────────────
[HttpGet("api/v1/environment/{apiKey}/identity/{id}/segments")] [HttpGet("{apiKey}/identities/{id}/segments")]
public async Task<ActionResult<IReadOnlyList<Segments.SegmentSummaryResponse>>> GetIdentitySegments( public async Task<ActionResult<IReadOnlyList<Segments.SegmentSummaryResponse>>> GetIdentitySegments(
string apiKey, int id, string apiKey, int id,
[FromServices] Identities.AdminIdentityService adminIdentityService, [FromServices] Identities.AdminIdentityService adminIdentityService,

View File

@@ -8,8 +8,7 @@ public record FeatureResponse(
string? Description, string? Description,
bool DefaultEnabled, bool DefaultEnabled,
int ProjectId, int ProjectId,
DateTimeOffset CreatedAt, DateTimeOffset CreatedAt
IReadOnlyList<TagResponse> Tags
) )
{ {
public static FeatureResponse From(Feature feature) => new( public static FeatureResponse From(Feature feature) => new(
@@ -20,6 +19,5 @@ public record FeatureResponse(
feature.Description, feature.Description,
feature.DefaultEnabled, feature.DefaultEnabled,
feature.ProjectId, feature.ProjectId,
feature.CreatedAt, feature.CreatedAt);
feature.Tags.Select(TagResponse.From).ToList());
} }

View File

@@ -13,14 +13,13 @@ public class FeatureService(MicCheckDbContext db, AuditService auditService, Web
public async Task<IReadOnlyList<Feature>> ListByProjectAsync(int projectId, CancellationToken ct = default) public async Task<IReadOnlyList<Feature>> ListByProjectAsync(int projectId, CancellationToken ct = default)
{ {
return await db.Features return await db.Features
.Include(f => f.Tags)
.Where(f => f.ProjectId == projectId) .Where(f => f.ProjectId == projectId)
.ToListAsync(ct); .ToListAsync(ct);
} }
public async Task<Feature?> FindByIdAsync(int id, CancellationToken ct = default) public async Task<Feature?> FindByIdAsync(int id, CancellationToken ct = default)
{ {
return await db.Features.Include(f => f.Tags).FirstOrDefaultAsync(f => f.Id == id, ct); return await db.Features.FirstOrDefaultAsync(f => f.Id == id, ct);
} }
public async Task<Feature> CreateAsync( public async Task<Feature> CreateAsync(
@@ -81,7 +80,7 @@ public class FeatureService(MicCheckDbContext db, AuditService auditService, Web
public async Task<Feature> UpdateAsync( public async Task<Feature> UpdateAsync(
int id, string name, string? description, CancellationToken ct = default) int id, string name, string? description, CancellationToken ct = default)
{ {
var feature = await db.Features.Include(f => f.Tags).FirstOrDefaultAsync(f => f.Id == id, ct) var feature = await db.Features.FirstOrDefaultAsync(f => f.Id == id, ct)
?? throw new KeyNotFoundException($"Feature {id} not found."); ?? throw new KeyNotFoundException($"Feature {id} not found.");
var before = new { feature.Name, feature.Description }; var before = new { feature.Name, feature.Description };
@@ -102,40 +101,6 @@ public class FeatureService(MicCheckDbContext db, AuditService auditService, Web
return feature; return feature;
} }
public async Task<Feature> AssignTagAsync(int featureId, int tagId, CancellationToken ct = default)
{
var feature = await db.Features.Include(f => f.Tags).FirstOrDefaultAsync(f => f.Id == featureId, ct)
?? throw new KeyNotFoundException($"Feature {featureId} not found.");
var tag = await db.Tags.FirstOrDefaultAsync(t => t.Id == tagId, ct)
?? throw new KeyNotFoundException($"Tag {tagId} not found.");
if (tag.ProjectId != feature.ProjectId)
throw new DomainException("Tag does not belong to the feature's project.");
if (feature.Tags.All(t => t.Id != tagId))
{
feature.Tags.Add(tag);
await db.SaveChangesAsync(ct);
}
return feature;
}
public async Task<Feature> RemoveTagAsync(int featureId, int tagId, CancellationToken ct = default)
{
var feature = await db.Features.Include(f => f.Tags).FirstOrDefaultAsync(f => f.Id == featureId, ct)
?? throw new KeyNotFoundException($"Feature {featureId} not found.");
var tag = feature.Tags.FirstOrDefault(t => t.Id == tagId);
if (tag is not null)
{
feature.Tags.Remove(tag);
await db.SaveChangesAsync(ct);
}
return feature;
}
public async Task DeleteAsync(int id, CancellationToken ct = default) public async Task DeleteAsync(int id, CancellationToken ct = default)
{ {
var feature = await db.Features.FirstOrDefaultAsync(f => f.Id == id, ct); var feature = await db.Features.FirstOrDefaultAsync(f => f.Id == id, ct);

View File

@@ -7,7 +7,7 @@ using Microsoft.Extensions.Caching.Memory;
namespace MicCheck.Api.Features; namespace MicCheck.Api.Features;
[ApiController] [ApiController]
[Route("api/v1/environment/{environmentId}/usage")] [Route("api/v1/environments/{environmentId}/usage")]
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)] [Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
[EnableRateLimiting("AdminApi")] [EnableRateLimiting("AdminApi")]
public class FeatureUsageController(FeatureUsageQueryService queryService, IMemoryCache cache) : ControllerBase public class FeatureUsageController(FeatureUsageQueryService queryService, IMemoryCache cache) : ControllerBase

View File

@@ -7,11 +7,12 @@ using Microsoft.AspNetCore.RateLimiting;
namespace MicCheck.Api.Features; namespace MicCheck.Api.Features;
[ApiController] [ApiController]
[Route("api/v1/projects/{projectId}/features")]
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)] [Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
[EnableRateLimiting("AdminApi")] [EnableRateLimiting("AdminApi")]
public class FeaturesController(FeatureService featureService) : ControllerBase public class FeaturesController(FeatureService featureService) : ControllerBase
{ {
[HttpGet("api/v1/project/{projectId}/features")] [HttpGet]
public async Task<ActionResult<PaginatedResponse<FeatureResponse>>> List( public async Task<ActionResult<PaginatedResponse<FeatureResponse>>> List(
int projectId, int projectId,
[FromQuery] int page = 1, [FromQuery] int page = 1,
@@ -24,7 +25,7 @@ public class FeaturesController(FeatureService featureService) : ControllerBase
return Ok(new PaginatedResponse<FeatureResponse>(all.Count, null, null, paged)); return Ok(new PaginatedResponse<FeatureResponse>(all.Count, null, null, paged));
} }
[HttpPost("api/v1/project/{projectId}/features")] [HttpPost]
public async Task<ActionResult<FeatureResponse>> Create( public async Task<ActionResult<FeatureResponse>> Create(
int projectId, CreateFeatureRequest request, CancellationToken ct) int projectId, CreateFeatureRequest request, CancellationToken ct)
{ {
@@ -40,7 +41,7 @@ public class FeaturesController(FeatureService featureService) : ControllerBase
} }
} }
[HttpGet("api/v1/project/{projectId}/feature/{id}")] [HttpGet("{id}")]
public async Task<ActionResult<FeatureResponse>> GetById(int projectId, int id, CancellationToken ct) public async Task<ActionResult<FeatureResponse>> GetById(int projectId, int id, CancellationToken ct)
{ {
var feature = await featureService.FindByIdAsync(id, ct); var feature = await featureService.FindByIdAsync(id, ct);
@@ -48,7 +49,7 @@ public class FeaturesController(FeatureService featureService) : ControllerBase
return Ok(FeatureResponse.From(feature)); return Ok(FeatureResponse.From(feature));
} }
[HttpPut("api/v1/project/{projectId}/feature/{id}")] [HttpPut("{id}")]
public async Task<ActionResult<FeatureResponse>> Update( public async Task<ActionResult<FeatureResponse>> Update(
int projectId, int id, UpdateFeatureRequest request, CancellationToken ct) int projectId, int id, UpdateFeatureRequest request, CancellationToken ct)
{ {
@@ -59,7 +60,7 @@ public class FeaturesController(FeatureService featureService) : ControllerBase
return Ok(FeatureResponse.From(updated)); return Ok(FeatureResponse.From(updated));
} }
[HttpPatch("api/v1/project/{projectId}/feature/{id}")] [HttpPatch("{id}")]
public async Task<ActionResult<FeatureResponse>> Patch( public async Task<ActionResult<FeatureResponse>> Patch(
int projectId, int id, PatchFeatureRequest request, CancellationToken ct) int projectId, int id, PatchFeatureRequest request, CancellationToken ct)
{ {
@@ -74,7 +75,7 @@ public class FeaturesController(FeatureService featureService) : ControllerBase
return Ok(FeatureResponse.From(updated)); return Ok(FeatureResponse.From(updated));
} }
[HttpDelete("api/v1/project/{projectId}/feature/{id}")] [HttpDelete("{id}")]
public async Task<IActionResult> Delete(int projectId, int id, CancellationToken ct) public async Task<IActionResult> Delete(int projectId, int id, CancellationToken ct)
{ {
var feature = await featureService.FindByIdAsync(id, ct); var feature = await featureService.FindByIdAsync(id, ct);
@@ -83,35 +84,4 @@ public class FeaturesController(FeatureService featureService) : ControllerBase
await featureService.DeleteAsync(id, ct); await featureService.DeleteAsync(id, ct);
return NoContent(); return NoContent();
} }
[HttpPut("api/v1/project/{projectId}/feature/{id}/tag/{tagId}")]
public async Task<ActionResult<FeatureResponse>> AssignTag(int projectId, int id, int tagId, CancellationToken ct)
{
var feature = await featureService.FindByIdAsync(id, ct);
if (feature is null || feature.ProjectId != projectId) return NotFound();
try
{
var updated = await featureService.AssignTagAsync(id, tagId, ct);
return Ok(FeatureResponse.From(updated));
}
catch (KeyNotFoundException)
{
return NotFound();
}
catch (DomainException ex)
{
return BadRequest(new { error = ex.Message });
}
}
[HttpDelete("api/v1/project/{projectId}/feature/{id}/tag/{tagId}")]
public async Task<ActionResult<FeatureResponse>> RemoveTag(int projectId, int id, int tagId, CancellationToken ct)
{
var feature = await featureService.FindByIdAsync(id, ct);
if (feature is null || feature.ProjectId != projectId) return NotFound();
var updated = await featureService.RemoveTagAsync(id, tagId, ct);
return Ok(FeatureResponse.From(updated));
}
} }

View File

@@ -6,25 +6,26 @@ using Microsoft.AspNetCore.RateLimiting;
namespace MicCheck.Api.Features; namespace MicCheck.Api.Features;
[ApiController] [ApiController]
[Route("api/v1/projects/{projectId}/tags")]
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)] [Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
[EnableRateLimiting("AdminApi")] [EnableRateLimiting("AdminApi")]
public class TagsController(TagService tagService) : ControllerBase public class TagsController(TagService tagService) : ControllerBase
{ {
[HttpGet("api/v1/project/{projectId}/tags")] [HttpGet]
public async Task<ActionResult<IReadOnlyList<TagResponse>>> List(int projectId, CancellationToken ct) public async Task<ActionResult<IReadOnlyList<TagResponse>>> List(int projectId, CancellationToken ct)
{ {
var tags = await tagService.ListByProjectAsync(projectId, ct); var tags = await tagService.ListByProjectAsync(projectId, ct);
return Ok(tags.Select(TagResponse.From).ToList()); return Ok(tags.Select(TagResponse.From).ToList());
} }
[HttpPost("api/v1/project/{projectId}/tags")] [HttpPost]
public async Task<ActionResult<TagResponse>> Create(int projectId, CreateTagRequest request, CancellationToken ct) public async Task<ActionResult<TagResponse>> Create(int projectId, CreateTagRequest request, CancellationToken ct)
{ {
var tag = await tagService.CreateAsync(projectId, request.Label, request.Color, ct); var tag = await tagService.CreateAsync(projectId, request.Label, request.Color, ct);
return CreatedAtAction(nameof(List), new { projectId }, TagResponse.From(tag)); return CreatedAtAction(nameof(List), new { projectId }, TagResponse.From(tag));
} }
[HttpDelete("api/v1/project/{projectId}/tag/{id}")] [HttpDelete("{id}")]
public async Task<IActionResult> Delete(int projectId, int id, CancellationToken ct) public async Task<IActionResult> Delete(int projectId, int id, CancellationToken ct)
{ {
var tag = await tagService.FindByIdAsync(id, ct); var tag = await tagService.FindByIdAsync(id, ct);

View File

@@ -6,7 +6,7 @@ using Microsoft.AspNetCore.Mvc;
namespace MicCheck.Api.Identities; namespace MicCheck.Api.Identities;
[ApiController] [ApiController]
[Route("api/v1/identity")] [Route("api/v1/identities")]
[Authorize(Policy = AuthorizationPolicies.FlagsApiAccess)] [Authorize(Policy = AuthorizationPolicies.FlagsApiAccess)]
public class IdentitiesController( public class IdentitiesController(
FeatureEvaluationService featureEvaluationService, FeatureEvaluationService featureEvaluationService,

View File

@@ -8,11 +8,12 @@ using Microsoft.AspNetCore.RateLimiting;
namespace MicCheck.Api.Organizations; namespace MicCheck.Api.Organizations;
[ApiController] [ApiController]
[Route("api/v1/organisations")]
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)] [Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
[EnableRateLimiting("AdminApi")] [EnableRateLimiting("AdminApi")]
public class OrganizationsController(OrganizationService organizationService, WebhookService webhookService) : ControllerBase public class OrganizationsController(OrganizationService organizationService, WebhookService webhookService) : ControllerBase
{ {
[HttpGet("api/v1/organisations")] [HttpGet]
public async Task<ActionResult<PaginatedResponse<OrganizationResponse>>> List( public async Task<ActionResult<PaginatedResponse<OrganizationResponse>>> List(
[FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken ct = default) [FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken ct = default)
{ {
@@ -27,7 +28,7 @@ public class OrganizationsController(OrganizationService organizationService, We
return Ok(new PaginatedResponse<OrganizationResponse>(all.Count, null, null, paged)); return Ok(new PaginatedResponse<OrganizationResponse>(all.Count, null, null, paged));
} }
[HttpPost("api/v1/organisations")] [HttpPost]
public async Task<ActionResult<OrganizationResponse>> Create( public async Task<ActionResult<OrganizationResponse>> Create(
CreateOrganizationRequest request, CancellationToken ct) CreateOrganizationRequest request, CancellationToken ct)
{ {
@@ -38,7 +39,7 @@ public class OrganizationsController(OrganizationService organizationService, We
return CreatedAtAction(nameof(GetById), new { id = org.Id }, OrganizationResponse.From(org)); return CreatedAtAction(nameof(GetById), new { id = org.Id }, OrganizationResponse.From(org));
} }
[HttpGet("api/v1/organisation/{id}")] [HttpGet("{id}")]
public async Task<ActionResult<OrganizationResponse>> GetById(int id, CancellationToken ct) public async Task<ActionResult<OrganizationResponse>> GetById(int id, CancellationToken ct)
{ {
var org = await organizationService.FindByIdAsync(id, ct); var org = await organizationService.FindByIdAsync(id, ct);
@@ -46,7 +47,7 @@ public class OrganizationsController(OrganizationService organizationService, We
return Ok(OrganizationResponse.From(org)); return Ok(OrganizationResponse.From(org));
} }
[HttpPut("api/v1/organisation/{id}")] [HttpPut("{id}")]
public async Task<ActionResult<OrganizationResponse>> Update( public async Task<ActionResult<OrganizationResponse>> Update(
int id, UpdateOrganizationRequest request, CancellationToken ct) int id, UpdateOrganizationRequest request, CancellationToken ct)
{ {
@@ -57,7 +58,7 @@ public class OrganizationsController(OrganizationService organizationService, We
return Ok(OrganizationResponse.From(updated)); return Ok(OrganizationResponse.From(updated));
} }
[HttpPut("api/v1/organisation/{id}/primary")] [HttpPut("{id}/primary")]
public async Task<IActionResult> SetPrimary(int id, CancellationToken ct) public async Task<IActionResult> SetPrimary(int id, CancellationToken ct)
{ {
var userId = GetCurrentUserId(); var userId = GetCurrentUserId();
@@ -74,7 +75,7 @@ public class OrganizationsController(OrganizationService organizationService, We
} }
} }
[HttpDelete("api/v1/organisation/{id}")] [HttpDelete("{id}")]
public async Task<IActionResult> Delete(int id, CancellationToken ct) public async Task<IActionResult> Delete(int id, CancellationToken ct)
{ {
var org = await organizationService.FindByIdAsync(id, ct); var org = await organizationService.FindByIdAsync(id, ct);
@@ -84,7 +85,7 @@ public class OrganizationsController(OrganizationService organizationService, We
return NoContent(); return NoContent();
} }
[HttpGet("api/v1/organisation/{id}/users")] [HttpGet("{id}/users")]
public async Task<ActionResult<IReadOnlyList<OrganizationMemberResponse>>> ListUsers( public async Task<ActionResult<IReadOnlyList<OrganizationMemberResponse>>> ListUsers(
int id, CancellationToken ct) int id, CancellationToken ct)
{ {
@@ -95,7 +96,7 @@ public class OrganizationsController(OrganizationService organizationService, We
return Ok(members.Select(OrganizationMemberResponse.From).ToList()); return Ok(members.Select(OrganizationMemberResponse.From).ToList());
} }
[HttpPost("api/v1/organisation/{id}/users/invite")] [HttpPost("{id}/users/invite")]
public async Task<IActionResult> InviteUser(int id, InviteUserRequest request, CancellationToken ct) public async Task<IActionResult> InviteUser(int id, InviteUserRequest request, CancellationToken ct)
{ {
var org = await organizationService.FindByIdAsync(id, ct); var org = await organizationService.FindByIdAsync(id, ct);
@@ -106,7 +107,7 @@ public class OrganizationsController(OrganizationService organizationService, We
return Ok(); return Ok();
} }
[HttpPost("api/v1/organisation/{id}/users/invite-by-email")] [HttpPost("{id}/users/invite-by-email")]
public async Task<ActionResult<IReadOnlyList<InviteByEmailResult>>> InviteUsersByEmail( public async Task<ActionResult<IReadOnlyList<InviteByEmailResult>>> InviteUsersByEmail(
int id, InviteUsersByEmailRequest request, CancellationToken ct) int id, InviteUsersByEmailRequest request, CancellationToken ct)
{ {
@@ -117,7 +118,7 @@ public class OrganizationsController(OrganizationService organizationService, We
return Ok(results); return Ok(results);
} }
[HttpGet("api/v1/organisation/{id}/invite-link")] [HttpGet("{id}/invite-link")]
public async Task<ActionResult<InviteTokenResponse>> GetInviteLink(int id, CancellationToken ct) public async Task<ActionResult<InviteTokenResponse>> GetInviteLink(int id, CancellationToken ct)
{ {
var org = await organizationService.FindByIdAsync(id, ct); var org = await organizationService.FindByIdAsync(id, ct);
@@ -127,7 +128,7 @@ public class OrganizationsController(OrganizationService organizationService, We
return Ok(new InviteTokenResponse(token)); return Ok(new InviteTokenResponse(token));
} }
[HttpPost("api/v1/organisation/{id}/invite-link/regenerate")] [HttpPost("{id}/invite-link/regenerate")]
public async Task<ActionResult<InviteTokenResponse>> RegenerateInviteLink(int id, CancellationToken ct) public async Task<ActionResult<InviteTokenResponse>> RegenerateInviteLink(int id, CancellationToken ct)
{ {
var org = await organizationService.FindByIdAsync(id, ct); var org = await organizationService.FindByIdAsync(id, ct);
@@ -137,7 +138,7 @@ public class OrganizationsController(OrganizationService organizationService, We
return Ok(new InviteTokenResponse(token)); return Ok(new InviteTokenResponse(token));
} }
[HttpPost("api/v1/organisations/invite/{token}/accept")] [HttpPost("invite/{token}/accept")]
public async Task<IActionResult> AcceptInvite(string token, CancellationToken ct) public async Task<IActionResult> AcceptInvite(string token, CancellationToken ct)
{ {
var userId = GetCurrentUserId(); var userId = GetCurrentUserId();
@@ -154,7 +155,7 @@ public class OrganizationsController(OrganizationService organizationService, We
} }
} }
[HttpDelete("api/v1/organisation/{id}/user/{userId}")] [HttpDelete("{id}/users/{userId}")]
public async Task<IActionResult> RemoveUser(int id, int userId, CancellationToken ct) public async Task<IActionResult> RemoveUser(int id, int userId, CancellationToken ct)
{ {
var org = await organizationService.FindByIdAsync(id, ct); var org = await organizationService.FindByIdAsync(id, ct);
@@ -164,7 +165,7 @@ public class OrganizationsController(OrganizationService organizationService, We
return NoContent(); return NoContent();
} }
[HttpGet("api/v1/organisation/{id}/webhooks")] [HttpGet("{id}/webhooks")]
public async Task<ActionResult<IReadOnlyList<WebhookResponse>>> ListWebhooks(int id, CancellationToken ct) public async Task<ActionResult<IReadOnlyList<WebhookResponse>>> ListWebhooks(int id, CancellationToken ct)
{ {
var org = await organizationService.FindByIdAsync(id, ct); var org = await organizationService.FindByIdAsync(id, ct);
@@ -174,7 +175,7 @@ public class OrganizationsController(OrganizationService organizationService, We
return Ok(webhooks.Select(WebhookResponse.From).ToList()); return Ok(webhooks.Select(WebhookResponse.From).ToList());
} }
[HttpPost("api/v1/organisation/{id}/webhooks")] [HttpPost("{id}/webhooks")]
public async Task<ActionResult<WebhookResponse>> CreateWebhook( public async Task<ActionResult<WebhookResponse>> CreateWebhook(
int id, CreateWebhookRequest request, CancellationToken ct) int id, CreateWebhookRequest request, CancellationToken ct)
{ {
@@ -185,7 +186,7 @@ public class OrganizationsController(OrganizationService organizationService, We
return CreatedAtAction(nameof(ListWebhooks), new { id }, WebhookResponse.From(webhook)); return CreatedAtAction(nameof(ListWebhooks), new { id }, WebhookResponse.From(webhook));
} }
[HttpPut("api/v1/organisation/{id}/webhook/{webhookId}")] [HttpPut("{id}/webhooks/{webhookId}")]
public async Task<ActionResult<WebhookResponse>> UpdateWebhook( public async Task<ActionResult<WebhookResponse>> UpdateWebhook(
int id, int webhookId, CreateWebhookRequest request, CancellationToken ct) int id, int webhookId, CreateWebhookRequest request, CancellationToken ct)
{ {
@@ -199,7 +200,7 @@ public class OrganizationsController(OrganizationService organizationService, We
return Ok(WebhookResponse.From(updated)); return Ok(WebhookResponse.From(updated));
} }
[HttpDelete("api/v1/organisation/{id}/webhook/{webhookId}")] [HttpDelete("{id}/webhooks/{webhookId}")]
public async Task<IActionResult> DeleteWebhook(int id, int webhookId, CancellationToken ct) public async Task<IActionResult> DeleteWebhook(int id, int webhookId, CancellationToken ct)
{ {
var org = await organizationService.FindByIdAsync(id, ct); var org = await organizationService.FindByIdAsync(id, ct);

View File

@@ -7,11 +7,12 @@ using Microsoft.AspNetCore.RateLimiting;
namespace MicCheck.Api.Projects; namespace MicCheck.Api.Projects;
[ApiController] [ApiController]
[Route("api/v1/projects")]
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)] [Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
[EnableRateLimiting("AdminApi")] [EnableRateLimiting("AdminApi")]
public class ProjectsController(ProjectService projectService) : ControllerBase public class ProjectsController(ProjectService projectService) : ControllerBase
{ {
[HttpGet("api/v1/projects")] [HttpGet]
public async Task<ActionResult<PaginatedResponse<ProjectResponse>>> List( public async Task<ActionResult<PaginatedResponse<ProjectResponse>>> List(
[FromQuery] int organizationId, [FromQuery] int organizationId,
[FromQuery] int page = 1, [FromQuery] int page = 1,
@@ -24,14 +25,14 @@ public class ProjectsController(ProjectService projectService) : ControllerBase
return Ok(new PaginatedResponse<ProjectResponse>(all.Count, null, null, paged)); return Ok(new PaginatedResponse<ProjectResponse>(all.Count, null, null, paged));
} }
[HttpPost("api/v1/projects")] [HttpPost]
public async Task<ActionResult<ProjectResponse>> Create(CreateProjectRequest request, CancellationToken ct) public async Task<ActionResult<ProjectResponse>> Create(CreateProjectRequest request, CancellationToken ct)
{ {
var project = await projectService.CreateAsync(request.OrganizationId, request.Name, ct); var project = await projectService.CreateAsync(request.OrganizationId, request.Name, ct);
return CreatedAtAction(nameof(GetById), new { id = project.Id }, ProjectResponse.From(project)); return CreatedAtAction(nameof(GetById), new { id = project.Id }, ProjectResponse.From(project));
} }
[HttpGet("api/v1/project/{id}")] [HttpGet("{id}")]
public async Task<ActionResult<ProjectResponse>> GetById(int id, CancellationToken ct) public async Task<ActionResult<ProjectResponse>> GetById(int id, CancellationToken ct)
{ {
var project = await projectService.FindByIdAsync(id, ct); var project = await projectService.FindByIdAsync(id, ct);
@@ -39,7 +40,7 @@ public class ProjectsController(ProjectService projectService) : ControllerBase
return Ok(ProjectResponse.From(project)); return Ok(ProjectResponse.From(project));
} }
[HttpPut("api/v1/project/{id}")] [HttpPut("{id}")]
public async Task<ActionResult<ProjectResponse>> Update(int id, UpdateProjectRequest request, CancellationToken ct) public async Task<ActionResult<ProjectResponse>> Update(int id, UpdateProjectRequest request, CancellationToken ct)
{ {
var project = await projectService.FindByIdAsync(id, ct); var project = await projectService.FindByIdAsync(id, ct);
@@ -49,7 +50,7 @@ public class ProjectsController(ProjectService projectService) : ControllerBase
return Ok(ProjectResponse.From(updated)); return Ok(ProjectResponse.From(updated));
} }
[HttpDelete("api/v1/project/{id}")] [HttpDelete("{id}")]
public async Task<IActionResult> Delete(int id, CancellationToken ct) public async Task<IActionResult> Delete(int id, CancellationToken ct)
{ {
var project = await projectService.FindByIdAsync(id, ct); var project = await projectService.FindByIdAsync(id, ct);
@@ -59,7 +60,7 @@ public class ProjectsController(ProjectService projectService) : ControllerBase
return NoContent(); return NoContent();
} }
[HttpGet("api/v1/project/{id}/user-permissions")] [HttpGet("{id}/user-permissions")]
public async Task<ActionResult<IReadOnlyList<UserPermissionResponse>>> ListUserPermissions( public async Task<ActionResult<IReadOnlyList<UserPermissionResponse>>> ListUserPermissions(
int id, CancellationToken ct) int id, CancellationToken ct)
{ {
@@ -70,7 +71,7 @@ public class ProjectsController(ProjectService projectService) : ControllerBase
return Ok(perms.Select(UserPermissionResponse.From).ToList()); return Ok(perms.Select(UserPermissionResponse.From).ToList());
} }
[HttpPost("api/v1/project/{id}/user-permissions")] [HttpPost("{id}/user-permissions")]
public async Task<ActionResult<UserPermissionResponse>> CreateUserPermissions( public async Task<ActionResult<UserPermissionResponse>> CreateUserPermissions(
int id, SetUserPermissionsRequest request, CancellationToken ct) int id, SetUserPermissionsRequest request, CancellationToken ct)
{ {
@@ -85,7 +86,7 @@ public class ProjectsController(ProjectService projectService) : ControllerBase
return Ok(UserPermissionResponse.From(perm)); return Ok(UserPermissionResponse.From(perm));
} }
[HttpPut("api/v1/project/{id}/user-permission/{userId}")] [HttpPut("{id}/user-permissions/{userId}")]
public async Task<ActionResult<UserPermissionResponse>> UpdateUserPermissions( public async Task<ActionResult<UserPermissionResponse>> UpdateUserPermissions(
int id, int userId, SetUserPermissionsRequest request, CancellationToken ct) int id, int userId, SetUserPermissionsRequest request, CancellationToken ct)
{ {
@@ -100,7 +101,7 @@ public class ProjectsController(ProjectService projectService) : ControllerBase
return Ok(UserPermissionResponse.From(perm)); return Ok(UserPermissionResponse.From(perm));
} }
[HttpDelete("api/v1/project/{id}/user-permission/{userId}")] [HttpDelete("{id}/user-permissions/{userId}")]
public async Task<IActionResult> DeleteUserPermissions(int id, int userId, CancellationToken ct) public async Task<IActionResult> DeleteUserPermissions(int id, int userId, CancellationToken ct)
{ {
var project = await projectService.FindByIdAsync(id, ct); var project = await projectService.FindByIdAsync(id, ct);

View File

@@ -7,11 +7,12 @@ using Microsoft.AspNetCore.RateLimiting;
namespace MicCheck.Api.Segments; namespace MicCheck.Api.Segments;
[ApiController] [ApiController]
[Route("api/v1/projects/{projectId}/segments")]
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)] [Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
[EnableRateLimiting("AdminApi")] [EnableRateLimiting("AdminApi")]
public class SegmentsController(SegmentService segmentService) : ControllerBase public class SegmentsController(SegmentService segmentService) : ControllerBase
{ {
[HttpGet("api/v1/project/{projectId}/segments")] [HttpGet]
public async Task<ActionResult<PaginatedResponse<SegmentResponse>>> List( public async Task<ActionResult<PaginatedResponse<SegmentResponse>>> List(
int projectId, int projectId,
[FromQuery] int page = 1, [FromQuery] int page = 1,
@@ -24,7 +25,7 @@ public class SegmentsController(SegmentService segmentService) : ControllerBase
return Ok(new PaginatedResponse<SegmentResponse>(all.Count, null, null, paged)); return Ok(new PaginatedResponse<SegmentResponse>(all.Count, null, null, paged));
} }
[HttpPost("api/v1/project/{projectId}/segments")] [HttpPost]
public async Task<ActionResult<SegmentResponse>> Create( public async Task<ActionResult<SegmentResponse>> Create(
int projectId, CreateSegmentRequest request, CancellationToken ct) int projectId, CreateSegmentRequest request, CancellationToken ct)
{ {
@@ -40,7 +41,7 @@ public class SegmentsController(SegmentService segmentService) : ControllerBase
} }
} }
[HttpGet("api/v1/project/{projectId}/segment/{id}")] [HttpGet("{id}")]
public async Task<ActionResult<SegmentResponse>> GetById(int projectId, int id, CancellationToken ct) public async Task<ActionResult<SegmentResponse>> GetById(int projectId, int id, CancellationToken ct)
{ {
var segment = await segmentService.FindByIdAsync(id, ct); var segment = await segmentService.FindByIdAsync(id, ct);
@@ -48,7 +49,7 @@ public class SegmentsController(SegmentService segmentService) : ControllerBase
return Ok(SegmentResponse.From(segment)); return Ok(SegmentResponse.From(segment));
} }
[HttpPut("api/v1/project/{projectId}/segment/{id}")] [HttpPut("{id}")]
public async Task<ActionResult<SegmentResponse>> Update( public async Task<ActionResult<SegmentResponse>> Update(
int projectId, int id, CreateSegmentRequest request, CancellationToken ct) int projectId, int id, CreateSegmentRequest request, CancellationToken ct)
{ {
@@ -67,7 +68,7 @@ public class SegmentsController(SegmentService segmentService) : ControllerBase
} }
} }
[HttpDelete("api/v1/project/{projectId}/segment/{id}")] [HttpDelete("{id}")]
public async Task<IActionResult> Delete(int projectId, int id, CancellationToken ct) public async Task<IActionResult> Delete(int projectId, int id, CancellationToken ct)
{ {
var segment = await segmentService.FindByIdAsync(id, ct); var segment = await segmentService.FindByIdAsync(id, ct);

View File

@@ -6,7 +6,7 @@ using Microsoft.AspNetCore.RateLimiting;
namespace MicCheck.Api.Users; namespace MicCheck.Api.Users;
[ApiController] [ApiController]
[Route("api/v1/user")] [Route("api/v1/users")]
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)] [Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
[EnableRateLimiting("AdminApi")] [EnableRateLimiting("AdminApi")]
public class UsersController(UserService userService) : ControllerBase public class UsersController(UserService userService) : ControllerBase

View File

@@ -97,7 +97,7 @@ public class AdminApiIntegrationTests
db.Projects.Add(project); db.Projects.Add(project);
db.SaveChanges(); db.SaveChanges();
var response = await client.PostAsJsonAsync($"/api/v1/project/{project.Id}/features", new var response = await client.PostAsJsonAsync($"/api/v1/projects/{project.Id}/features", new
{ {
name = "dark_mode", name = "dark_mode",
type = "Standard", type = "Standard",
@@ -135,7 +135,7 @@ public class AdminApiIntegrationTests
}); });
db.SaveChanges(); db.SaveChanges();
await client.PostAsJsonAsync($"/api/v1/project/{project.Id}/features", new await client.PostAsJsonAsync($"/api/v1/projects/{project.Id}/features", new
{ {
name = "flag_x", name = "flag_x",
type = "Standard", type = "Standard",
@@ -220,7 +220,7 @@ public class AdminApiIntegrationTests
db.Projects.Add(project); db.Projects.Add(project);
db.SaveChanges(); db.SaveChanges();
var response = await client.PostAsJsonAsync($"/api/v1/project/{project.Id}/features", new var response = await client.PostAsJsonAsync($"/api/v1/projects/{project.Id}/features", new
{ {
name = "invalid name with spaces!", name = "invalid name with spaces!",
type = "Standard" type = "Standard"
@@ -247,7 +247,7 @@ public class AdminApiIntegrationTests
db.Projects.Add(project); db.Projects.Add(project);
db.SaveChanges(); db.SaveChanges();
var response = await client.PostAsJsonAsync($"/api/v1/project/{project.Id}/segments", new var response = await client.PostAsJsonAsync($"/api/v1/projects/{project.Id}/segments", new
{ {
name = "Premium Users", name = "Premium Users",
rules = new[] rules = new[]

View File

@@ -46,7 +46,7 @@ public class ApiKeyAuthenticationHandlerTests
{ {
var client = _factory.CreateClient(); var client = _factory.CreateClient();
var response = await client.GetAsync("/api/v1/organisation/1/api-keys/"); var response = await client.GetAsync("/api/v1/organisations/1/api-keys/");
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
} }
@@ -58,7 +58,7 @@ public class ApiKeyAuthenticationHandlerTests
client.DefaultRequestHeaders.Authorization = client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", "not-a-jwt"); new AuthenticationHeaderValue("Bearer", "not-a-jwt");
var response = await client.GetAsync("/api/v1/organisation/1/api-keys/"); var response = await client.GetAsync("/api/v1/organisations/1/api-keys/");
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
} }
@@ -69,7 +69,7 @@ public class ApiKeyAuthenticationHandlerTests
var client = _factory.CreateClient(); var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("Authorization", "Api-Key not-a-real-key"); client.DefaultRequestHeaders.Add("Authorization", "Api-Key not-a-real-key");
var response = await client.GetAsync("/api/v1/organisation/1/api-keys/"); var response = await client.GetAsync("/api/v1/organisations/1/api-keys/");
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
} }
@@ -95,7 +95,7 @@ public class ApiKeyAuthenticationHandlerTests
var client = _factory.CreateClient(); var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("Authorization", $"Api-Key {rawKey}"); client.DefaultRequestHeaders.Add("Authorization", $"Api-Key {rawKey}");
var response = await client.GetAsync("/api/v1/organisation/1/api-keys/"); var response = await client.GetAsync("/api/v1/organisations/1/api-keys/");
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
} }
@@ -122,7 +122,7 @@ public class ApiKeyAuthenticationHandlerTests
var client = _factory.CreateClient(); var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("Authorization", $"Api-Key {rawKey}"); client.DefaultRequestHeaders.Add("Authorization", $"Api-Key {rawKey}");
var response = await client.GetAsync("/api/v1/organisation/1/api-keys/"); var response = await client.GetAsync("/api/v1/organisations/1/api-keys/");
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
} }
@@ -135,7 +135,7 @@ public class ApiKeyAuthenticationHandlerTests
var client = _factory.CreateClient(); var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("Authorization", $"Api-Key {rawKey}"); client.DefaultRequestHeaders.Add("Authorization", $"Api-Key {rawKey}");
var response = await client.GetAsync("/api/v1/organisation/1/api-keys/"); var response = await client.GetAsync("/api/v1/organisations/1/api-keys/");
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
} }

View File

@@ -46,7 +46,7 @@ public class ApiKeyAuthenticationHandlerTests
{ {
var client = _factory.CreateClient(); var client = _factory.CreateClient();
var response = await client.GetAsync("/api/v1/organisation/1/api-keys/"); var response = await client.GetAsync("/api/v1/organisations/1/api-keys/");
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
} }
@@ -58,7 +58,7 @@ public class ApiKeyAuthenticationHandlerTests
client.DefaultRequestHeaders.Authorization = client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", "not-a-jwt"); new AuthenticationHeaderValue("Bearer", "not-a-jwt");
var response = await client.GetAsync("/api/v1/organisation/1/api-keys/"); var response = await client.GetAsync("/api/v1/organisations/1/api-keys/");
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
} }
@@ -69,7 +69,7 @@ public class ApiKeyAuthenticationHandlerTests
var client = _factory.CreateClient(); var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("Authorization", "Api-Key not-a-real-key"); client.DefaultRequestHeaders.Add("Authorization", "Api-Key not-a-real-key");
var response = await client.GetAsync("/api/v1/organisation/1/api-keys/"); var response = await client.GetAsync("/api/v1/organisations/1/api-keys/");
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
} }
@@ -95,7 +95,7 @@ public class ApiKeyAuthenticationHandlerTests
var client = _factory.CreateClient(); var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("Authorization", $"Api-Key {rawKey}"); client.DefaultRequestHeaders.Add("Authorization", $"Api-Key {rawKey}");
var response = await client.GetAsync("/api/v1/organisation/1/api-keys/"); var response = await client.GetAsync("/api/v1/organisations/1/api-keys/");
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
} }
@@ -122,7 +122,7 @@ public class ApiKeyAuthenticationHandlerTests
var client = _factory.CreateClient(); var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("Authorization", $"Api-Key {rawKey}"); client.DefaultRequestHeaders.Add("Authorization", $"Api-Key {rawKey}");
var response = await client.GetAsync("/api/v1/organisation/1/api-keys/"); var response = await client.GetAsync("/api/v1/organisations/1/api-keys/");
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
} }
@@ -135,7 +135,7 @@ public class ApiKeyAuthenticationHandlerTests
var client = _factory.CreateClient(); var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("Authorization", $"Api-Key {rawKey}"); client.DefaultRequestHeaders.Add("Authorization", $"Api-Key {rawKey}");
var response = await client.GetAsync("/api/v1/organisation/1/api-keys/"); var response = await client.GetAsync("/api/v1/organisations/1/api-keys/");
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
} }

View File

@@ -137,63 +137,4 @@ public class FeatureServiceTests
Assert.That(features, Has.Count.EqualTo(2)); Assert.That(features, Has.Count.EqualTo(2));
} }
[Test]
public async Task WhenAssigningATag_ThenItAppearsOnTheFeature()
{
var feature = await _service.CreateAsync(ProjectId, "flag", FeatureType.Standard, null, null);
var tag = new Tag { Label = "beta", Color = "#FF0000", ProjectId = ProjectId };
_db.Tags.Add(tag);
await _db.SaveChangesAsync();
var updated = await _service.AssignTagAsync(feature.Id, tag.Id);
Assert.That(updated.Tags.Select(t => t.Id), Does.Contain(tag.Id));
}
[Test]
public async Task WhenAssigningATagAlreadyOnTheFeature_ThenItIsNotDuplicated()
{
var feature = await _service.CreateAsync(ProjectId, "flag", FeatureType.Standard, null, null);
var tag = new Tag { Label = "beta", Color = "#FF0000", ProjectId = ProjectId };
_db.Tags.Add(tag);
await _db.SaveChangesAsync();
await _service.AssignTagAsync(feature.Id, tag.Id);
var updated = await _service.AssignTagAsync(feature.Id, tag.Id);
Assert.That(updated.Tags.Count(t => t.Id == tag.Id), Is.EqualTo(1));
}
[Test]
public async Task WhenAssigningATagFromAnotherProject_ThenDomainExceptionIsThrown()
{
_db.Projects.Add(new MicCheck.Api.Projects.Project
{
Id = 2,
Name = "Other Project",
OrganizationId = OrganizationId,
CreatedAt = DateTimeOffset.UtcNow
});
var feature = await _service.CreateAsync(ProjectId, "flag", FeatureType.Standard, null, null);
var foreignTag = new Tag { Label = "beta", Color = "#FF0000", ProjectId = 2 };
_db.Tags.Add(foreignTag);
await _db.SaveChangesAsync();
Assert.ThrowsAsync<DomainException>(() => _service.AssignTagAsync(feature.Id, foreignTag.Id));
}
[Test]
public async Task WhenRemovingAnAssignedTag_ThenItNoLongerAppearsOnTheFeature()
{
var feature = await _service.CreateAsync(ProjectId, "flag", FeatureType.Standard, null, null);
var tag = new Tag { Label = "beta", Color = "#FF0000", ProjectId = ProjectId };
_db.Tags.Add(tag);
await _db.SaveChangesAsync();
await _service.AssignTagAsync(feature.Id, tag.Id);
var updated = await _service.RemoveTagAsync(feature.Id, tag.Id);
Assert.That(updated.Tags.Select(t => t.Id), Does.Not.Contain(tag.Id));
}
} }

View File

@@ -115,7 +115,7 @@ public class FlagsApiIntegrationTests
var client = _factory.CreateClient(); var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Environment-Key", _envApiKey); client.DefaultRequestHeaders.Add("X-Environment-Key", _envApiKey);
var response = await client.PostAsJsonAsync("/api/v1/identity/", new var response = await client.PostAsJsonAsync("/api/v1/identities/", new
{ {
identifier = "user-123", identifier = "user-123",
traits = new[] { new { trait_key = "plan", trait_value = "premium" } } traits = new[] { new { trait_key = "plan", trait_value = "premium" } }