Compare commits

..

13 Commits

Author SHA1 Message Date
James Wampler
a1de374d22 Move org switcher to header, self-host Inter, add feature tags, normalize API route casing
- Replace sidebar org dropdown with a header link/menu (left of theme toggle) that
  includes a "New Organization" entry opening the create dialog
- Self-host Inter via @fontsource-variable/inter instead of relying on system fonts
- Wire up real feature<->tag assignment (was UI-only before): expose Tags on
  FeatureResponse, add assign/remove endpoints, make tag chips in the feature
  detail dialog toggle assignment, and show up to 5 tags (+ellipsis) in the
  features table next to the segment-override icon
- Normalize all API routes to singular/plural REST convention: singular resource
  name when addressing one item by id/key (e.g. /feature/{id}), plural for
  list/create endpoints (e.g. /features). Updated every controller, the admin
  frontend API clients, and affected tests to match
2026-06-30 13:36:18 -07:00
James Wampler
5179f5479c 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.
2026-06-30 11:45:35 -07:00
James Wampler
5dcd39f564 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.
2026-06-30 11:45:27 -07:00
James Wampler
5506151f19 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.
2026-06-30 11:45:17 -07:00
James Wampler
47f8ab19bb Wrap long method signatures and remove unused using to comply with 220-char line limit 2026-06-30 11:45:08 -07:00
James Wampler
c57a13b350 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
2026-06-30 11:44:55 -07:00
James Wampler
1fddec7281 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.
2026-06-30 11:44:43 -07:00
James Wampler
f2cc5245bc 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.
2026-06-30 11:44:31 -07:00
James Wampler
f317a47041 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.
2026-06-30 11:44:21 -07:00
James Wampler
f077fe1d8d 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.
2026-06-30 11:44:10 -07:00
James Wampler
b871e0da32 WIP 2026-06-30 11:44:10 -07:00
James Wampler
d1a41d84d7 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.
2026-06-30 11:43:50 -07:00
James Wampler
d916549402 New project file, added Aspire, moved security 2026-06-30 11:42:37 -07:00
43 changed files with 564 additions and 186 deletions

View File

@@ -8,6 +8,7 @@
"name": "mic-check-admin",
"version": "1.0.0",
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@iconify-json/bxl": "^1.2.0",
"@iconify-json/ri": "^1.2.0",
"@iconify/vue": "^4.1.0",
@@ -2002,6 +2003,14 @@
"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": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",

View File

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

View File

@@ -1,4 +1,4 @@
$font-family-custom: "Inter", sans-serif, -apple-system, blinkmacsystemfont, "Segoe UI", roboto,
$font-family-custom: "Inter Variable", sans-serif, -apple-system, blinkmacsystemfont, "Segoe UI", roboto,
"Helvetica Neue", arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
/* stylelint-disable length-zero-no-unit */
@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[]> {
const { data } = await apiClient.get<ApiKeyResponse[]>(
`/v1/organisations/${organizationId}/api-keys`,
`/v1/organisation/${organizationId}/api-keys`,
);
return data;
}
@@ -13,12 +13,12 @@ export async function createApiKey(
request: CreateApiKeyRequest,
): Promise<CreateApiKeyResponse> {
const { data } = await apiClient.post<CreateApiKeyResponse>(
`/v1/organisations/${organizationId}/api-keys`,
`/v1/organisation/${organizationId}/api-keys`,
request,
);
return data;
}
export async function deleteApiKey(organizationId: number, keyId: number): Promise<void> {
await apiClient.delete(`/v1/organisations/${organizationId}/api-keys/${keyId}`);
await apiClient.delete(`/v1/organisation/${organizationId}/api-key/${keyId}`);
}

View File

@@ -17,7 +17,7 @@ export async function listAuditLogsByOrganization(
filter: AuditLogFilter = {},
): Promise<PaginatedResponse<AuditLogResponse>> {
const { data } = await apiClient.get<PaginatedResponse<AuditLogResponse>>(
`/v1/organisations/${orgId}/audit-logs`,
`/v1/organisation/${orgId}/audit-logs`,
{ params: toParams(filter) },
);
return data;
@@ -28,7 +28,7 @@ export async function listAuditLogsByProject(
filter: AuditLogFilter = {},
): Promise<PaginatedResponse<AuditLogResponse>> {
const { data } = await apiClient.get<PaginatedResponse<AuditLogResponse>>(
`/v1/projects/${projectId}/audit-logs`,
`/v1/project/${projectId}/audit-logs`,
{ params: toParams(filter) },
);
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> {
const { data } = await apiClient.get<EnvironmentResponse>(`/v1/environments/${apiKey}`);
const { data } = await apiClient.get<EnvironmentResponse>(`/v1/environment/${apiKey}`);
return data;
}
@@ -26,17 +26,17 @@ export async function createEnvironment(request: CreateEnvironmentRequest): Prom
}
export async function updateEnvironment(apiKey: string, request: UpdateEnvironmentRequest): Promise<EnvironmentResponse> {
const { data } = await apiClient.put<EnvironmentResponse>(`/v1/environments/${apiKey}`, request);
const { data } = await apiClient.put<EnvironmentResponse>(`/v1/environment/${apiKey}`, request);
return data;
}
export async function deleteEnvironment(apiKey: string): Promise<void> {
await apiClient.delete(`/v1/environments/${apiKey}`);
await apiClient.delete(`/v1/environment/${apiKey}`);
}
export async function cloneEnvironment(apiKey: string, request: CloneEnvironmentRequest): Promise<EnvironmentResponse> {
const { data } = await apiClient.post<EnvironmentResponse>(
`/v1/environments/${apiKey}/clone`,
`/v1/environment/${apiKey}/clone`,
request,
);
return data;

View File

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

View File

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

View File

@@ -13,7 +13,7 @@ export async function listFeatures(
pageSize = 100,
): Promise<PaginatedResponse<FeatureResponse>> {
const { data } = await apiClient.get<PaginatedResponse<FeatureResponse>>(
`/v1/projects/${projectId}/features`,
`/v1/project/${projectId}/features`,
{ params: { page, pageSize } },
);
return data;
@@ -21,7 +21,7 @@ export async function listFeatures(
export async function getFeature(projectId: number, id: number): Promise<FeatureResponse> {
const { data } = await apiClient.get<FeatureResponse>(
`/v1/projects/${projectId}/features/${id}`,
`/v1/project/${projectId}/feature/${id}`,
);
return data;
}
@@ -31,7 +31,7 @@ export async function createFeature(
request: CreateFeatureRequest,
): Promise<FeatureResponse> {
const { data } = await apiClient.post<FeatureResponse>(
`/v1/projects/${projectId}/features`,
`/v1/project/${projectId}/features`,
request,
);
return data;
@@ -43,7 +43,7 @@ export async function updateFeature(
request: UpdateFeatureRequest,
): Promise<FeatureResponse> {
const { data } = await apiClient.put<FeatureResponse>(
`/v1/projects/${projectId}/features/${id}`,
`/v1/project/${projectId}/feature/${id}`,
request,
);
return data;
@@ -55,12 +55,34 @@ export async function patchFeature(
request: PatchFeatureRequest,
): Promise<FeatureResponse> {
const { data } = await apiClient.patch<FeatureResponse>(
`/v1/projects/${projectId}/features/${id}`,
`/v1/project/${projectId}/feature/${id}`,
request,
);
return data;
}
export async function deleteFeature(projectId: number, id: number): Promise<void> {
await apiClient.delete(`/v1/projects/${projectId}/features/${id}`);
await apiClient.delete(`/v1/project/${projectId}/feature/${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,
): Promise<PaginatedResponse<IdentityResponse>> {
const { data } = await apiClient.get<PaginatedResponse<IdentityResponse>>(
`/v1/environments/${envApiKey}/identities`,
`/v1/environment/${envApiKey}/identities`,
{ params: { page, pageSize } },
);
return data;
@@ -26,7 +26,7 @@ export async function createIdentity(
request: CreateIdentityRequest,
): Promise<IdentityResponse> {
const { data } = await apiClient.post<IdentityResponse>(
`/v1/environments/${envApiKey}/identities`,
`/v1/environment/${envApiKey}/identities`,
request,
);
return data;
@@ -34,7 +34,7 @@ export async function createIdentity(
export async function getIdentity(envApiKey: string, id: number): Promise<IdentityResponse> {
const { data } = await apiClient.get<IdentityResponse>(
`/v1/environments/${envApiKey}/identities/${id}`,
`/v1/environment/${envApiKey}/identity/${id}`,
);
return data;
}
@@ -46,7 +46,7 @@ export async function upsertIdentityTrait(
request: UpsertTraitRequest,
): Promise<TraitResponse> {
const { data } = await apiClient.put<TraitResponse>(
`/v1/environments/${envApiKey}/identities/${identityId}/traits/${encodeURIComponent(key)}`,
`/v1/environment/${envApiKey}/identity/${identityId}/trait/${encodeURIComponent(key)}`,
request,
);
return data;
@@ -58,12 +58,12 @@ export async function deleteIdentityTrait(
key: string,
): Promise<void> {
await apiClient.delete(
`/v1/environments/${envApiKey}/identities/${identityId}/traits/${encodeURIComponent(key)}`,
`/v1/environment/${envApiKey}/identity/${identityId}/trait/${encodeURIComponent(key)}`,
);
}
export async function deleteIdentity(envApiKey: string, id: number): Promise<void> {
await apiClient.delete(`/v1/environments/${envApiKey}/identities/${id}`);
await apiClient.delete(`/v1/environment/${envApiKey}/identity/${id}`);
}
export async function getIdentityFeatureStates(
@@ -71,7 +71,7 @@ export async function getIdentityFeatureStates(
identityId: number,
): Promise<FeatureStateResponse[]> {
const { data } = await apiClient.get<FeatureStateResponse[]>(
`/v1/environments/${envApiKey}/identities/${identityId}/featurestates`,
`/v1/environment/${envApiKey}/identity/${identityId}/featurestates`,
);
return data;
}
@@ -83,7 +83,7 @@ export async function setIdentityFeatureState(
request: UpdateFeatureStateRequest,
): Promise<FeatureStateResponse> {
const { data } = await apiClient.put<FeatureStateResponse>(
`/v1/environments/${envApiKey}/identities/${identityId}/featurestates/${featureId}`,
`/v1/environment/${envApiKey}/identity/${identityId}/featurestate/${featureId}`,
request,
);
return data;
@@ -95,6 +95,6 @@ export async function deleteIdentityFeatureState(
featureId: number,
): Promise<void> {
await apiClient.delete(
`/v1/environments/${envApiKey}/identities/${identityId}/featurestates/${featureId}`,
`/v1/environment/${envApiKey}/identity/${identityId}/featurestate/${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> {
const { data } = await apiClient.get<OrganizationResponse>(`/v1/organisations/${id}`);
const { data } = await apiClient.get<OrganizationResponse>(`/v1/organisation/${id}`);
return data;
}
@@ -30,27 +30,27 @@ export async function createOrganization(request: CreateOrganizationRequest): Pr
}
export async function updateOrganization(id: number, request: UpdateOrganizationRequest): Promise<OrganizationResponse> {
const { data } = await apiClient.put<OrganizationResponse>(`/v1/organisations/${id}`, request);
const { data } = await apiClient.put<OrganizationResponse>(`/v1/organisation/${id}`, request);
return data;
}
export async function deleteOrganization(id: number): Promise<void> {
await apiClient.delete(`/v1/organisations/${id}`);
await apiClient.delete(`/v1/organisation/${id}`);
}
export async function listOrganizationMembers(organizationId: number): Promise<OrganizationMemberResponse[]> {
const { data } = await apiClient.get<OrganizationMemberResponse[]>(
`/v1/organisations/${organizationId}/users`,
`/v1/organisation/${organizationId}/users`,
);
return data;
}
export async function inviteOrganizationMember(organizationId: number, request: InviteUserRequest): Promise<void> {
await apiClient.post(`/v1/organisations/${organizationId}/users/invite`, request);
await apiClient.post(`/v1/organisation/${organizationId}/users/invite`, request);
}
export async function removeOrganizationMember(organizationId: number, userId: number): Promise<void> {
await apiClient.delete(`/v1/organisations/${organizationId}/users/${userId}`);
await apiClient.delete(`/v1/organisation/${organizationId}/user/${userId}`);
}
export async function inviteOrganizationMembersByEmail(
@@ -58,7 +58,7 @@ export async function inviteOrganizationMembersByEmail(
request: InviteUsersByEmailRequest,
): Promise<InviteByEmailResult[]> {
const { data } = await apiClient.post<InviteByEmailResult[]>(
`/v1/organisations/${organizationId}/users/invite-by-email`,
`/v1/organisation/${organizationId}/users/invite-by-email`,
request,
);
return data;
@@ -66,14 +66,14 @@ export async function inviteOrganizationMembersByEmail(
export async function getInviteLink(organizationId: number): Promise<InviteTokenResponse> {
const { data } = await apiClient.get<InviteTokenResponse>(
`/v1/organisations/${organizationId}/invite-link`,
`/v1/organisation/${organizationId}/invite-link`,
);
return data;
}
export async function regenerateInviteLink(organizationId: number): Promise<InviteTokenResponse> {
const { data } = await apiClient.post<InviteTokenResponse>(
`/v1/organisations/${organizationId}/invite-link/regenerate`,
`/v1/organisation/${organizationId}/invite-link/regenerate`,
);
return data;
}
@@ -83,5 +83,5 @@ export async function acceptInvite(token: string): Promise<void> {
}
export async function setPrimaryOrganization(id: number): Promise<void> {
await apiClient.put(`/v1/organisations/${id}/primary`);
await apiClient.put(`/v1/organisation/${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> {
const { data } = await apiClient.get<ProjectResponse>(`/v1/projects/${id}`);
const { data } = await apiClient.get<ProjectResponse>(`/v1/project/${id}`);
return data;
}
@@ -27,17 +27,17 @@ export async function createProject(request: CreateProjectRequest): Promise<Proj
}
export async function updateProject(id: number, request: UpdateProjectRequest): Promise<ProjectResponse> {
const { data } = await apiClient.put<ProjectResponse>(`/v1/projects/${id}`, request);
const { data } = await apiClient.put<ProjectResponse>(`/v1/project/${id}`, request);
return data;
}
export async function deleteProject(id: number): Promise<void> {
await apiClient.delete(`/v1/projects/${id}`);
await apiClient.delete(`/v1/project/${id}`);
}
export async function listProjectUserPermissions(projectId: number): Promise<UserPermissionResponse[]> {
const { data } = await apiClient.get<UserPermissionResponse[]>(
`/v1/projects/${projectId}/user-permissions`,
`/v1/project/${projectId}/user-permissions`,
);
return data;
}
@@ -47,7 +47,7 @@ export async function setProjectUserPermissions(
request: SetUserPermissionsRequest,
): Promise<UserPermissionResponse> {
const { data } = await apiClient.post<UserPermissionResponse>(
`/v1/projects/${projectId}/user-permissions`,
`/v1/project/${projectId}/user-permissions`,
request,
);
return data;
@@ -59,12 +59,12 @@ export async function updateProjectUserPermissions(
request: SetUserPermissionsRequest,
): Promise<UserPermissionResponse> {
const { data } = await apiClient.put<UserPermissionResponse>(
`/v1/projects/${projectId}/user-permissions/${userId}`,
`/v1/project/${projectId}/user-permission/${userId}`,
request,
);
return data;
}
export async function removeProjectUserPermissions(projectId: number, userId: number): Promise<void> {
await apiClient.delete(`/v1/projects/${projectId}/user-permissions/${userId}`);
await apiClient.delete(`/v1/project/${projectId}/user-permission/${userId}`);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -53,7 +53,7 @@
<!-- Name + description -->
<template #item.name="{ item }: { item: FeatureResponse }">
<div>
<div class="d-flex align-center ga-1">
<div class="d-flex flex-wrap align-center ga-1">
<span class="text-subtitle-1 font-weight-bold">{{ item.name }}</span>
<v-tooltip
v-if="(featureSegmentsMap.get(item.id) ?? []).length > 0"
@@ -72,6 +72,21 @@
/>
</template>
</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>
<p v-if="item.description" class="text-caption text-medium-emphasis mb-0">
{{ item.description }}

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,13 +13,14 @@ public class FeatureService(MicCheckDbContext db, AuditService auditService, Web
public async Task<IReadOnlyList<Feature>> ListByProjectAsync(int projectId, CancellationToken ct = default)
{
return await db.Features
.Include(f => f.Tags)
.Where(f => f.ProjectId == projectId)
.ToListAsync(ct);
}
public async Task<Feature?> FindByIdAsync(int id, CancellationToken ct = default)
{
return await db.Features.FirstOrDefaultAsync(f => f.Id == id, ct);
return await db.Features.Include(f => f.Tags).FirstOrDefaultAsync(f => f.Id == id, ct);
}
public async Task<Feature> CreateAsync(
@@ -80,7 +81,7 @@ public class FeatureService(MicCheckDbContext db, AuditService auditService, Web
public async Task<Feature> UpdateAsync(
int id, string name, string? description, CancellationToken ct = default)
{
var feature = await db.Features.FirstOrDefaultAsync(f => f.Id == id, ct)
var feature = await db.Features.Include(f => f.Tags).FirstOrDefaultAsync(f => f.Id == id, ct)
?? throw new KeyNotFoundException($"Feature {id} not found.");
var before = new { feature.Name, feature.Description };
@@ -101,6 +102,40 @@ public class FeatureService(MicCheckDbContext db, AuditService auditService, Web
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)
{
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;
[ApiController]
[Route("api/v1/environments/{environmentId}/usage")]
[Route("api/v1/environment/{environmentId}/usage")]
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
[EnableRateLimiting("AdminApi")]
public class FeatureUsageController(FeatureUsageQueryService queryService, IMemoryCache cache) : ControllerBase

View File

@@ -7,12 +7,11 @@ using Microsoft.AspNetCore.RateLimiting;
namespace MicCheck.Api.Features;
[ApiController]
[Route("api/v1/projects/{projectId}/features")]
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
[EnableRateLimiting("AdminApi")]
public class FeaturesController(FeatureService featureService) : ControllerBase
{
[HttpGet]
[HttpGet("api/v1/project/{projectId}/features")]
public async Task<ActionResult<PaginatedResponse<FeatureResponse>>> List(
int projectId,
[FromQuery] int page = 1,
@@ -25,7 +24,7 @@ public class FeaturesController(FeatureService featureService) : ControllerBase
return Ok(new PaginatedResponse<FeatureResponse>(all.Count, null, null, paged));
}
[HttpPost]
[HttpPost("api/v1/project/{projectId}/features")]
public async Task<ActionResult<FeatureResponse>> Create(
int projectId, CreateFeatureRequest request, CancellationToken ct)
{
@@ -41,7 +40,7 @@ public class FeaturesController(FeatureService featureService) : ControllerBase
}
}
[HttpGet("{id}")]
[HttpGet("api/v1/project/{projectId}/feature/{id}")]
public async Task<ActionResult<FeatureResponse>> GetById(int projectId, int id, CancellationToken ct)
{
var feature = await featureService.FindByIdAsync(id, ct);
@@ -49,7 +48,7 @@ public class FeaturesController(FeatureService featureService) : ControllerBase
return Ok(FeatureResponse.From(feature));
}
[HttpPut("{id}")]
[HttpPut("api/v1/project/{projectId}/feature/{id}")]
public async Task<ActionResult<FeatureResponse>> Update(
int projectId, int id, UpdateFeatureRequest request, CancellationToken ct)
{
@@ -60,7 +59,7 @@ public class FeaturesController(FeatureService featureService) : ControllerBase
return Ok(FeatureResponse.From(updated));
}
[HttpPatch("{id}")]
[HttpPatch("api/v1/project/{projectId}/feature/{id}")]
public async Task<ActionResult<FeatureResponse>> Patch(
int projectId, int id, PatchFeatureRequest request, CancellationToken ct)
{
@@ -75,7 +74,7 @@ public class FeaturesController(FeatureService featureService) : ControllerBase
return Ok(FeatureResponse.From(updated));
}
[HttpDelete("{id}")]
[HttpDelete("api/v1/project/{projectId}/feature/{id}")]
public async Task<IActionResult> Delete(int projectId, int id, CancellationToken ct)
{
var feature = await featureService.FindByIdAsync(id, ct);
@@ -84,4 +83,35 @@ public class FeaturesController(FeatureService featureService) : ControllerBase
await featureService.DeleteAsync(id, ct);
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,26 +6,25 @@ using Microsoft.AspNetCore.RateLimiting;
namespace MicCheck.Api.Features;
[ApiController]
[Route("api/v1/projects/{projectId}/tags")]
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
[EnableRateLimiting("AdminApi")]
public class TagsController(TagService tagService) : ControllerBase
{
[HttpGet]
[HttpGet("api/v1/project/{projectId}/tags")]
public async Task<ActionResult<IReadOnlyList<TagResponse>>> List(int projectId, CancellationToken ct)
{
var tags = await tagService.ListByProjectAsync(projectId, ct);
return Ok(tags.Select(TagResponse.From).ToList());
}
[HttpPost]
[HttpPost("api/v1/project/{projectId}/tags")]
public async Task<ActionResult<TagResponse>> Create(int projectId, CreateTagRequest request, CancellationToken ct)
{
var tag = await tagService.CreateAsync(projectId, request.Label, request.Color, ct);
return CreatedAtAction(nameof(List), new { projectId }, TagResponse.From(tag));
}
[HttpDelete("{id}")]
[HttpDelete("api/v1/project/{projectId}/tag/{id}")]
public async Task<IActionResult> Delete(int projectId, int id, CancellationToken ct)
{
var tag = await tagService.FindByIdAsync(id, ct);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -137,4 +137,63 @@ public class FeatureServiceTests
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();
client.DefaultRequestHeaders.Add("X-Environment-Key", _envApiKey);
var response = await client.PostAsJsonAsync("/api/v1/identities/", new
var response = await client.PostAsJsonAsync("/api/v1/identity/", new
{
identifier = "user-123",
traits = new[] { new { trait_key = "plan", trait_value = "premium" } }