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
This commit is contained in:
2026-06-30 13:36:18 -07:00
parent 5179f5479c
commit a1de374d22
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" } }