UX tweaks, traits bug fix, adding member info
This commit is contained in:
6
.vscode/settings.json
vendored
6
.vscode/settings.json
vendored
@@ -3,13 +3,13 @@
|
|||||||
{
|
{
|
||||||
"ssh": "Disabled",
|
"ssh": "Disabled",
|
||||||
"previewLimit": 50,
|
"previewLimit": 50,
|
||||||
"server": "mic-check-db-1",
|
"server": "localhost",
|
||||||
"port": 5432,
|
"port": 5432,
|
||||||
"askForPassword": true,
|
"askForPassword": true,
|
||||||
"driver": "PostgreSQL",
|
"driver": "PostgreSQL",
|
||||||
"name": "MicCheck - local",
|
"name": "MicCheck - local",
|
||||||
"database": "micheck",
|
"database": "miccheck",
|
||||||
"username": "micheck"
|
"username": "miccheck"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
29
dev-build.sh
Executable file
29
dev-build.sh
Executable file
@@ -0,0 +1,29 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
build_api() {
|
||||||
|
echo "Building API..."
|
||||||
|
dotnet publish "$SCRIPT_DIR/src/api/MicCheck.Api/MicCheck.Api.csproj" -c Release -o "$SCRIPT_DIR/src/api/MicCheck.Api/publish"
|
||||||
|
docker compose -f "$SCRIPT_DIR/docker-compose.yml" restart api
|
||||||
|
echo "API done."
|
||||||
|
}
|
||||||
|
|
||||||
|
build_admin() {
|
||||||
|
echo "Building admin..."
|
||||||
|
npm --prefix "$SCRIPT_DIR/src/admin" ci --silent
|
||||||
|
npm --prefix "$SCRIPT_DIR/src/admin" run build
|
||||||
|
docker compose -f "$SCRIPT_DIR/docker-compose.yml" restart admin
|
||||||
|
echo "Admin done."
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-all}" in
|
||||||
|
api) build_api ;;
|
||||||
|
admin) build_admin ;;
|
||||||
|
all) build_api && build_admin ;;
|
||||||
|
*)
|
||||||
|
echo "Usage: $0 [api|admin|all]"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -24,6 +24,8 @@ services:
|
|||||||
dockerfile: src/api/MicCheck.Api/Dockerfile
|
dockerfile: src/api/MicCheck.Api/Dockerfile
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- ./src/api/MicCheck.Api/publish:/app
|
||||||
environment:
|
environment:
|
||||||
ASPNETCORE_ENVIRONMENT: Development
|
ASPNETCORE_ENVIRONMENT: Development
|
||||||
ASPNETCORE_URLS: http://+:8080
|
ASPNETCORE_URLS: http://+:8080
|
||||||
@@ -48,6 +50,8 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
ports:
|
ports:
|
||||||
- "3000:80"
|
- "3000:80"
|
||||||
|
volumes:
|
||||||
|
- ./src/admin/dist:/usr/share/nginx/html
|
||||||
depends_on:
|
depends_on:
|
||||||
api:
|
api:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
|||||||
@@ -1,19 +1,4 @@
|
|||||||
# ── Stage 1: Build ────────────────────────────────────────────────────────────
|
FROM nginx:1.27-alpine
|
||||||
FROM node:20-alpine AS build
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Install dependencies first (cached layer unless package files change)
|
|
||||||
COPY package*.json ./
|
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
# Copy source and build
|
|
||||||
COPY . .
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
# ── Stage 2: Serve ────────────────────────────────────────────────────────────
|
|
||||||
FROM nginx:1.27-alpine AS serve
|
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|||||||
@@ -8,5 +8,6 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -9,7 +9,9 @@ server {
|
|||||||
# The API controller routes include the /api prefix, so the path is
|
# The API controller routes include the /api prefix, so the path is
|
||||||
# forwarded unchanged: /api/v1/organisations → http://api:8080/api/v1/organisations
|
# forwarded unchanged: /api/v1/organisations → http://api:8080/api/v1/organisations
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://api:8080;
|
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||||
|
set $api_upstream http://api:8080;
|
||||||
|
proxy_pass $api_upstream;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|||||||
4471
src/admin/package-lock.json
generated
4471
src/admin/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -3,13 +3,14 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "webpack --mode production --config webpack.config.js",
|
"build": "vite build",
|
||||||
"build:dev": "webpack --mode development --config webpack.config.js",
|
"build:dev": "vite build --mode development",
|
||||||
"serve": "webpack serve --mode development --config webpack.config.js",
|
"serve": "vite",
|
||||||
|
"preview": "vite preview",
|
||||||
"test": "jest --passWithNoTests"
|
"test": "jest --passWithNoTests"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mdi/font": "^7.4.47",
|
"@mdi/js": "^7.4.47",
|
||||||
"axios": "^1.15.0",
|
"axios": "^1.15.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.4.0",
|
"vue": "^3.4.0",
|
||||||
@@ -22,24 +23,17 @@
|
|||||||
"@babel/preset-typescript": "^7.24.0",
|
"@babel/preset-typescript": "^7.24.0",
|
||||||
"@types/jest": "^29.5.12",
|
"@types/jest": "^29.5.12",
|
||||||
"@types/node": "^20.12.0",
|
"@types/node": "^20.12.0",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.6",
|
||||||
"@vue/test-utils": "^2.4.6",
|
"@vue/test-utils": "^2.4.6",
|
||||||
"@vue/vue3-jest": "^29.2.6",
|
"@vue/vue3-jest": "^29.2.6",
|
||||||
"babel-jest": "^29.7.0",
|
"babel-jest": "^29.7.0",
|
||||||
"css-loader": "^7.1.2",
|
|
||||||
"html-webpack-plugin": "^5.6.0",
|
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"jest-environment-jsdom": "^29.7.0",
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
"sass": "^1.77.2",
|
"sass": "^1.77.2",
|
||||||
"sass-loader": "^14.2.1",
|
|
||||||
"style-loader": "^4.0.0",
|
|
||||||
"ts-jest": "^29.1.4",
|
"ts-jest": "^29.1.4",
|
||||||
"ts-loader": "^9.5.1",
|
|
||||||
"typescript": "^5.4.5",
|
"typescript": "^5.4.5",
|
||||||
"vue-loader": "^17.4.2",
|
"vite": "^8.0.8",
|
||||||
"webpack": "^5.91.0",
|
"vite-plugin-vuetify": "^2.1.3"
|
||||||
"webpack-cli": "^5.1.4",
|
|
||||||
"webpack-dev-server": "^5.0.4",
|
|
||||||
"webpack-plugin-vuetify": "^3.1.0"
|
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"brace-expansion": "^5.0.5"
|
"brace-expansion": "^5.0.5"
|
||||||
|
|||||||
60
src/admin/src/api/featureSegments.ts
Normal file
60
src/admin/src/api/featureSegments.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import apiClient from './client';
|
||||||
|
import type {
|
||||||
|
FeatureSegmentResponse,
|
||||||
|
CreateFeatureSegmentRequest,
|
||||||
|
UpdateFeatureSegmentRequest,
|
||||||
|
SegmentSummaryResponse,
|
||||||
|
} from '@/types/api';
|
||||||
|
|
||||||
|
export async function listFeatureSegments(
|
||||||
|
envApiKey: string,
|
||||||
|
featureId: number,
|
||||||
|
): Promise<FeatureSegmentResponse[]> {
|
||||||
|
const { data } = await apiClient.get<FeatureSegmentResponse[]>(
|
||||||
|
`/v1/environments/${envApiKey}/features/${featureId}/segments`,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createFeatureSegment(
|
||||||
|
envApiKey: string,
|
||||||
|
featureId: number,
|
||||||
|
request: CreateFeatureSegmentRequest,
|
||||||
|
): Promise<FeatureSegmentResponse> {
|
||||||
|
const { data } = await apiClient.post<FeatureSegmentResponse>(
|
||||||
|
`/v1/environments/${envApiKey}/features/${featureId}/segments`,
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateFeatureSegment(
|
||||||
|
envApiKey: string,
|
||||||
|
featureId: number,
|
||||||
|
id: number,
|
||||||
|
request: UpdateFeatureSegmentRequest,
|
||||||
|
): Promise<FeatureSegmentResponse> {
|
||||||
|
const { data } = await apiClient.put<FeatureSegmentResponse>(
|
||||||
|
`/v1/environments/${envApiKey}/features/${featureId}/segments/${id}`,
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteFeatureSegment(
|
||||||
|
envApiKey: string,
|
||||||
|
featureId: number,
|
||||||
|
id: number,
|
||||||
|
): Promise<void> {
|
||||||
|
await apiClient.delete(`/v1/environments/${envApiKey}/features/${featureId}/segments/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listIdentitySegments(
|
||||||
|
envApiKey: string,
|
||||||
|
identityId: number,
|
||||||
|
): Promise<SegmentSummaryResponse[]> {
|
||||||
|
const { data } = await apiClient.get<SegmentSummaryResponse[]>(
|
||||||
|
`/v1/environments/${envApiKey}/identities/${identityId}/segments`,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
import apiClient from './client';
|
import apiClient from './client';
|
||||||
import type {
|
import type {
|
||||||
IdentityResponse,
|
IdentityResponse,
|
||||||
|
TraitResponse,
|
||||||
FeatureStateResponse,
|
FeatureStateResponse,
|
||||||
UpdateFeatureStateRequest,
|
UpdateFeatureStateRequest,
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
|
CreateIdentityRequest,
|
||||||
|
UpsertTraitRequest,
|
||||||
} from '@/types/api';
|
} from '@/types/api';
|
||||||
|
|
||||||
export async function listIdentities(
|
export async function listIdentities(
|
||||||
@@ -18,6 +21,17 @@ export async function listIdentities(
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createIdentity(
|
||||||
|
envApiKey: string,
|
||||||
|
request: CreateIdentityRequest,
|
||||||
|
): Promise<IdentityResponse> {
|
||||||
|
const { data } = await apiClient.post<IdentityResponse>(
|
||||||
|
`/v1/environments/${envApiKey}/identities`,
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getIdentity(envApiKey: string, id: number): Promise<IdentityResponse> {
|
export async function getIdentity(envApiKey: string, id: number): Promise<IdentityResponse> {
|
||||||
const { data } = await apiClient.get<IdentityResponse>(
|
const { data } = await apiClient.get<IdentityResponse>(
|
||||||
`/v1/environments/${envApiKey}/identities/${id}`,
|
`/v1/environments/${envApiKey}/identities/${id}`,
|
||||||
@@ -25,6 +39,29 @@ export async function getIdentity(envApiKey: string, id: number): Promise<Identi
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function upsertIdentityTrait(
|
||||||
|
envApiKey: string,
|
||||||
|
identityId: number,
|
||||||
|
key: string,
|
||||||
|
request: UpsertTraitRequest,
|
||||||
|
): Promise<TraitResponse> {
|
||||||
|
const { data } = await apiClient.put<TraitResponse>(
|
||||||
|
`/v1/environments/${envApiKey}/identities/${identityId}/traits/${encodeURIComponent(key)}`,
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteIdentityTrait(
|
||||||
|
envApiKey: string,
|
||||||
|
identityId: number,
|
||||||
|
key: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await apiClient.delete(
|
||||||
|
`/v1/environments/${envApiKey}/identities/${identityId}/traits/${encodeURIComponent(key)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteIdentity(envApiKey: string, id: number): Promise<void> {
|
export async function deleteIdentity(envApiKey: string, id: number): Promise<void> {
|
||||||
await apiClient.delete(`/v1/environments/${envApiKey}/identities/${id}`);
|
await apiClient.delete(`/v1/environments/${envApiKey}/identities/${id}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,10 +49,10 @@
|
|||||||
width="240"
|
width="240"
|
||||||
>
|
>
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="d-flex align-center pa-4 pb-3" data-testid="sidebar-logo">
|
<router-link to="/dashboard" class="d-flex align-center pa-4 pb-3 text-decoration-none" data-testid="sidebar-logo">
|
||||||
<img :src="logoUrl" alt="MicCheck" height="32" width="32" class="flex-shrink-0" />
|
<img :src="logoUrl" alt="MicCheck" height="32" width="32" class="flex-shrink-0" />
|
||||||
<span v-if="!rail" class="text-h6 font-weight-bold ml-3">MicCheck</span>
|
<span v-if="!rail" class="text-h6 font-weight-bold ml-3 text-high-emphasis">MicCheck</span>
|
||||||
</div>
|
</router-link>
|
||||||
|
|
||||||
<v-divider />
|
<v-divider />
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
|
|
||||||
<v-tabs v-model="activeTab" color="primary" class="px-4">
|
<v-tabs v-model="activeTab" color="primary" class="px-4">
|
||||||
<v-tab value="value" data-testid="tab-value">Value</v-tab>
|
<v-tab value="value" data-testid="tab-value">Value</v-tab>
|
||||||
|
<v-tab value="segments" data-testid="tab-segments">Segments</v-tab>
|
||||||
<v-tab value="settings" data-testid="tab-settings">Settings</v-tab>
|
<v-tab value="settings" data-testid="tab-settings">Settings</v-tab>
|
||||||
</v-tabs>
|
</v-tabs>
|
||||||
<v-divider />
|
<v-divider />
|
||||||
@@ -97,6 +98,135 @@
|
|||||||
</template>
|
</template>
|
||||||
</v-tabs-window-item>
|
</v-tabs-window-item>
|
||||||
|
|
||||||
|
<!-- ── Segments tab ─────────────────────────────────────────── -->
|
||||||
|
<v-tabs-window-item value="segments" class="pa-6">
|
||||||
|
<v-alert
|
||||||
|
v-if="segmentsErrorMessage"
|
||||||
|
type="error"
|
||||||
|
variant="tonal"
|
||||||
|
density="compact"
|
||||||
|
closable
|
||||||
|
class="mb-4"
|
||||||
|
@click:close="segmentsErrorMessage = null"
|
||||||
|
>
|
||||||
|
{{ segmentsErrorMessage }}
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<div v-if="!featureState" class="text-center py-6 text-medium-emphasis">
|
||||||
|
<v-icon size="32" class="mb-2">mdi-server-outline</v-icon>
|
||||||
|
<p>Select an environment to manage segment overrides.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<!-- Existing segment overrides -->
|
||||||
|
<div v-if="featureSegments.length > 0" class="d-flex flex-column ga-3 mb-4">
|
||||||
|
<v-card
|
||||||
|
v-for="fsg in featureSegments"
|
||||||
|
:key="fsg.id"
|
||||||
|
variant="outlined"
|
||||||
|
rounded="lg"
|
||||||
|
:data-testid="`feature-segment-${fsg.id}`"
|
||||||
|
>
|
||||||
|
<v-card-text class="pa-3">
|
||||||
|
<div class="d-flex align-center justify-space-between">
|
||||||
|
<div class="d-flex align-center ga-2">
|
||||||
|
<v-icon size="18" color="primary">mdi-account-group-outline</v-icon>
|
||||||
|
<span class="text-body-2 font-weight-medium">{{ fsg.segmentName }}</span>
|
||||||
|
<v-chip size="x-small" variant="tonal" color="secondary">
|
||||||
|
Priority {{ fsg.priority }}
|
||||||
|
</v-chip>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-center ga-1">
|
||||||
|
<v-switch
|
||||||
|
:model-value="fsg.enabled ?? false"
|
||||||
|
color="primary"
|
||||||
|
hide-details
|
||||||
|
density="compact"
|
||||||
|
:data-testid="`segment-enabled-toggle-${fsg.id}`"
|
||||||
|
@update:model-value="onUpdateSegmentEnabled(fsg, $event)"
|
||||||
|
/>
|
||||||
|
<v-btn
|
||||||
|
icon="mdi-trash-can-outline"
|
||||||
|
size="x-small"
|
||||||
|
variant="text"
|
||||||
|
color="error"
|
||||||
|
:data-testid="`remove-segment-${fsg.id}`"
|
||||||
|
@click="onRemoveSegment(fsg.id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="fsg.value !== null" class="mt-2">
|
||||||
|
<v-text-field
|
||||||
|
:model-value="fsg.value"
|
||||||
|
label="Value"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
class="mt-1"
|
||||||
|
:data-testid="`segment-value-${fsg.id}`"
|
||||||
|
@update:model-value="onUpdateSegmentValue(fsg, $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-else class="text-body-2 text-medium-emphasis mb-4">
|
||||||
|
No segment overrides configured for this environment.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Add segment form -->
|
||||||
|
<v-card variant="outlined" rounded="lg" class="pa-4" data-testid="add-segment-form">
|
||||||
|
<p class="text-body-2 font-weight-medium mb-3">Add segment override</p>
|
||||||
|
<v-select
|
||||||
|
v-model="newSegmentId"
|
||||||
|
:items="availableSegments"
|
||||||
|
item-title="name"
|
||||||
|
item-value="id"
|
||||||
|
label="Segment"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
class="mb-3"
|
||||||
|
no-data-text="All segments already configured"
|
||||||
|
data-testid="new-segment-select"
|
||||||
|
/>
|
||||||
|
<div class="d-flex align-center ga-3 mb-3">
|
||||||
|
<v-switch
|
||||||
|
v-model="newSegmentEnabled"
|
||||||
|
color="primary"
|
||||||
|
label="Enabled"
|
||||||
|
hide-details
|
||||||
|
density="compact"
|
||||||
|
data-testid="new-segment-enabled"
|
||||||
|
/>
|
||||||
|
<v-text-field
|
||||||
|
v-model="newSegmentPriority"
|
||||||
|
label="Priority"
|
||||||
|
type="number"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
style="max-width: 100px"
|
||||||
|
data-testid="new-segment-priority"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-end">
|
||||||
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
size="small"
|
||||||
|
:disabled="!newSegmentId"
|
||||||
|
:loading="isAddingSegment"
|
||||||
|
data-testid="add-segment-btn"
|
||||||
|
@click="onAddSegment"
|
||||||
|
>
|
||||||
|
Add override
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
</v-tabs-window-item>
|
||||||
|
|
||||||
<!-- ── Settings tab ──────────────────────────────────────────── -->
|
<!-- ── Settings tab ──────────────────────────────────────────── -->
|
||||||
<v-tabs-window-item value="settings" class="pa-6">
|
<v-tabs-window-item value="settings" class="pa-6">
|
||||||
<v-alert
|
<v-alert
|
||||||
@@ -198,13 +328,15 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from 'vue';
|
import { ref, watch, computed } from 'vue';
|
||||||
import { patchFeature, deleteFeature } from '@/api/features';
|
import { patchFeature, deleteFeature } from '@/api/features';
|
||||||
import { patchFeatureState } from '@/api/featureStates';
|
import { patchFeatureState } from '@/api/featureStates';
|
||||||
|
import { listFeatureSegments, createFeatureSegment, updateFeatureSegment, deleteFeatureSegment } from '@/api/featureSegments';
|
||||||
|
import { listSegments } from '@/api/segments';
|
||||||
import { useContextStore } from '@/stores/context';
|
import { useContextStore } from '@/stores/context';
|
||||||
import FeatureValueEditor from './FeatureValueEditor.vue';
|
import FeatureValueEditor from './FeatureValueEditor.vue';
|
||||||
import TagsChipInput from './TagsChipInput.vue';
|
import TagsChipInput from './TagsChipInput.vue';
|
||||||
import type { FeatureResponse, FeatureStateResponse } from '@/types/api';
|
import type { FeatureResponse, FeatureStateResponse, FeatureSegmentResponse, SegmentResponse } from '@/types/api';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
modelValue: boolean;
|
modelValue: boolean;
|
||||||
@@ -233,6 +365,97 @@ const editedValue = ref<string | null>(null);
|
|||||||
const settingsFormRef = ref<{ validate: () => Promise<{ valid: boolean }> } | null>(null);
|
const settingsFormRef = ref<{ validate: () => Promise<{ valid: boolean }> } | null>(null);
|
||||||
const valueErrorMessage = ref<string | null>(null);
|
const valueErrorMessage = ref<string | null>(null);
|
||||||
const settingsErrorMessage = ref<string | null>(null);
|
const settingsErrorMessage = ref<string | null>(null);
|
||||||
|
const segmentsErrorMessage = ref<string | null>(null);
|
||||||
|
|
||||||
|
// Segments tab state
|
||||||
|
const featureSegments = ref<FeatureSegmentResponse[]>([]);
|
||||||
|
const allProjectSegments = ref<SegmentResponse[]>([]);
|
||||||
|
const newSegmentId = ref<number | null>(null);
|
||||||
|
const newSegmentEnabled = ref(true);
|
||||||
|
const newSegmentPriority = ref(1);
|
||||||
|
const isAddingSegment = ref(false);
|
||||||
|
|
||||||
|
const availableSegments = computed(() =>
|
||||||
|
allProjectSegments.value.filter(
|
||||||
|
(s) => !featureSegments.value.some((fsg) => fsg.segmentId === s.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
async function loadSegmentsData(): Promise<void> {
|
||||||
|
const envApiKey = contextStore.currentEnvironment?.apiKey;
|
||||||
|
const projectId = contextStore.currentProject?.id;
|
||||||
|
if (!envApiKey || !props.feature || !projectId) return;
|
||||||
|
try {
|
||||||
|
const [fsegs, segs] = await Promise.all([
|
||||||
|
listFeatureSegments(envApiKey, props.feature.id),
|
||||||
|
listSegments(projectId),
|
||||||
|
]);
|
||||||
|
featureSegments.value = fsegs;
|
||||||
|
allProjectSegments.value = segs;
|
||||||
|
} catch {
|
||||||
|
segmentsErrorMessage.value = 'Failed to load segment overrides.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onAddSegment(): Promise<void> {
|
||||||
|
const envApiKey = contextStore.currentEnvironment?.apiKey;
|
||||||
|
if (!envApiKey || !props.feature || !newSegmentId.value) return;
|
||||||
|
isAddingSegment.value = true;
|
||||||
|
segmentsErrorMessage.value = null;
|
||||||
|
try {
|
||||||
|
const created = await createFeatureSegment(envApiKey, props.feature.id, {
|
||||||
|
segmentId: newSegmentId.value,
|
||||||
|
priority: newSegmentPriority.value,
|
||||||
|
enabled: newSegmentEnabled.value,
|
||||||
|
value: null,
|
||||||
|
});
|
||||||
|
featureSegments.value = [...featureSegments.value, created];
|
||||||
|
newSegmentId.value = null;
|
||||||
|
newSegmentEnabled.value = true;
|
||||||
|
newSegmentPriority.value = featureSegments.value.length;
|
||||||
|
} catch {
|
||||||
|
segmentsErrorMessage.value = 'Failed to add segment override.';
|
||||||
|
} finally {
|
||||||
|
isAddingSegment.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUpdateSegmentEnabled(fsg: FeatureSegmentResponse, v: boolean | null): void {
|
||||||
|
onUpdateSegment(fsg, { enabled: !!v });
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUpdateSegmentValue(fsg: FeatureSegmentResponse, val: string): void {
|
||||||
|
onUpdateSegment(fsg, { value: val || null });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onUpdateSegment(
|
||||||
|
fsg: FeatureSegmentResponse,
|
||||||
|
patch: { enabled?: boolean; value?: string | null },
|
||||||
|
): Promise<void> {
|
||||||
|
const envApiKey = contextStore.currentEnvironment?.apiKey;
|
||||||
|
if (!envApiKey || !props.feature) return;
|
||||||
|
try {
|
||||||
|
const updated = await updateFeatureSegment(envApiKey, props.feature.id, fsg.id, {
|
||||||
|
priority: fsg.priority,
|
||||||
|
enabled: patch.enabled ?? fsg.enabled ?? false,
|
||||||
|
value: patch.value !== undefined ? patch.value : fsg.value,
|
||||||
|
});
|
||||||
|
featureSegments.value = featureSegments.value.map((f) => (f.id === fsg.id ? updated : f));
|
||||||
|
} catch {
|
||||||
|
segmentsErrorMessage.value = 'Failed to update segment override.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRemoveSegment(id: number): Promise<void> {
|
||||||
|
const envApiKey = contextStore.currentEnvironment?.apiKey;
|
||||||
|
if (!envApiKey || !props.feature) return;
|
||||||
|
try {
|
||||||
|
await deleteFeatureSegment(envApiKey, props.feature.id, id);
|
||||||
|
featureSegments.value = featureSegments.value.filter((f) => f.id !== id);
|
||||||
|
} catch {
|
||||||
|
segmentsErrorMessage.value = 'Failed to remove segment override.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const settingsForm = ref({
|
const settingsForm = ref({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -263,7 +486,7 @@ watch(() => props.feature, syncSettingsForm, { immediate: true });
|
|||||||
// Sync edited value when feature state changes
|
// Sync edited value when feature state changes
|
||||||
watch(
|
watch(
|
||||||
() => props.featureState,
|
() => props.featureState,
|
||||||
(s) => {
|
(s: FeatureStateResponse | null) => {
|
||||||
editedValue.value = s?.value ?? null;
|
editedValue.value = s?.value ?? null;
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
@@ -277,8 +500,14 @@ watch(
|
|||||||
activeTab.value = 'value';
|
activeTab.value = 'value';
|
||||||
valueErrorMessage.value = null;
|
valueErrorMessage.value = null;
|
||||||
settingsErrorMessage.value = null;
|
settingsErrorMessage.value = null;
|
||||||
|
segmentsErrorMessage.value = null;
|
||||||
|
featureSegments.value = [];
|
||||||
|
newSegmentId.value = null;
|
||||||
|
newSegmentEnabled.value = true;
|
||||||
|
newSegmentPriority.value = 1;
|
||||||
syncSettingsForm(props.feature);
|
syncSettingsForm(props.feature);
|
||||||
editedValue.value = props.featureState?.value ?? null;
|
editedValue.value = props.featureState?.value ?? null;
|
||||||
|
loadSegmentsData();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
<v-tabs v-model="activeTab" color="primary" class="px-4">
|
<v-tabs v-model="activeTab" color="primary" class="px-4">
|
||||||
<v-tab value="traits" data-testid="tab-traits">Traits</v-tab>
|
<v-tab value="traits" data-testid="tab-traits">Traits</v-tab>
|
||||||
<v-tab value="overrides" data-testid="tab-overrides">Feature Overrides</v-tab>
|
<v-tab value="overrides" data-testid="tab-overrides">Feature Overrides</v-tab>
|
||||||
|
<v-tab value="segments" data-testid="tab-segments">Segments</v-tab>
|
||||||
</v-tabs>
|
</v-tabs>
|
||||||
<v-divider />
|
<v-divider />
|
||||||
|
|
||||||
@@ -37,26 +38,82 @@
|
|||||||
{{ traitsError }}
|
{{ traitsError }}
|
||||||
</v-alert>
|
</v-alert>
|
||||||
|
|
||||||
<div v-if="identity.traits.length === 0" class="text-center py-6 text-medium-emphasis">
|
<div v-if="editedTraits.length === 0" class="text-center py-4 text-medium-emphasis text-body-2 mb-4" data-testid="no-traits-message">
|
||||||
<v-icon size="32" class="mb-2">mdi-tag-outline</v-icon>
|
No traits recorded for this identity.
|
||||||
<p class="text-body-2">No traits recorded for this identity.</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<v-table v-else density="compact" data-testid="traits-table">
|
<v-table v-else density="compact" class="mb-4" data-testid="traits-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Key</th>
|
<th style="width: 40%">Key</th>
|
||||||
<th>Value</th>
|
<th>Value</th>
|
||||||
|
<th style="width: 40px"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="trait in identity.traits" :key="trait.key" :data-testid="`trait-row-${trait.key}`">
|
<tr v-for="(trait, i) in editedTraits" :key="trait.key" :data-testid="`trait-row-${trait.key}`">
|
||||||
<td class="text-body-2 font-weight-medium">{{ trait.key }}</td>
|
<td class="text-body-2 font-weight-medium">{{ trait.key }}</td>
|
||||||
<td class="text-body-2">{{ trait.value }}</td>
|
<td>
|
||||||
|
<v-text-field
|
||||||
|
v-model="editedTraits[Number(i)].value"
|
||||||
|
variant="underlined"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
:loading="savingTraitKey === trait.key"
|
||||||
|
:data-testid="`trait-value-${trait.key}`"
|
||||||
|
@blur="onSaveTrait(trait.key, editedTraits[Number(i)].value)"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<v-btn
|
||||||
|
icon="mdi-trash-can-outline"
|
||||||
|
size="x-small"
|
||||||
|
variant="text"
|
||||||
|
color="error"
|
||||||
|
:loading="deletingTraitKey === trait.key"
|
||||||
|
:data-testid="`delete-trait-${trait.key}`"
|
||||||
|
@click="onDeleteTrait(trait.key)"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</v-table>
|
</v-table>
|
||||||
|
|
||||||
|
<!-- Add trait form -->
|
||||||
|
<v-divider class="mb-4" />
|
||||||
|
<p class="text-body-2 font-weight-medium mb-3">Add Trait</p>
|
||||||
|
<div class="d-flex align-center ga-3 flex-wrap">
|
||||||
|
<v-text-field
|
||||||
|
v-model="newTraitKey"
|
||||||
|
label="Key"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
style="min-width: 140px; flex: 1"
|
||||||
|
data-testid="new-trait-key-input"
|
||||||
|
/>
|
||||||
|
<v-text-field
|
||||||
|
v-model="newTraitValue"
|
||||||
|
label="Value"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
style="min-width: 140px; flex: 1"
|
||||||
|
data-testid="new-trait-value-input"
|
||||||
|
/>
|
||||||
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
size="small"
|
||||||
|
:disabled="!newTraitKey.trim()"
|
||||||
|
:loading="isAddingTrait"
|
||||||
|
data-testid="add-trait-btn"
|
||||||
|
@click="onAddTrait"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Danger zone -->
|
<!-- Danger zone -->
|
||||||
<v-card variant="outlined" color="error" rounded="lg" class="mt-6">
|
<v-card variant="outlined" color="error" rounded="lg" class="mt-6">
|
||||||
<v-card-title class="text-body-1 text-error pa-4 pb-2">Danger Zone</v-card-title>
|
<v-card-title class="text-body-1 text-error pa-4 pb-2">Danger Zone</v-card-title>
|
||||||
@@ -197,6 +254,48 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</v-tabs-window-item>
|
</v-tabs-window-item>
|
||||||
|
<!-- ── Segments tab ─────────────────────────────────────────── -->
|
||||||
|
<v-tabs-window-item value="segments" class="pa-6">
|
||||||
|
<v-alert
|
||||||
|
v-if="segmentsError"
|
||||||
|
type="error"
|
||||||
|
variant="tonal"
|
||||||
|
density="compact"
|
||||||
|
closable
|
||||||
|
class="mb-4"
|
||||||
|
@click:close="segmentsError = null"
|
||||||
|
>
|
||||||
|
{{ segmentsError }}
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<div v-if="isLoadingSegments" class="d-flex justify-center py-6">
|
||||||
|
<v-progress-circular indeterminate color="primary" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<p class="text-body-2 text-medium-emphasis mb-4">
|
||||||
|
Segments this identity belongs to are determined automatically by evaluating its traits against each segment's rules.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="identitySegments.length === 0" class="text-center py-6 text-medium-emphasis" data-testid="no-segments-message">
|
||||||
|
<v-icon size="32" class="mb-2">mdi-label-off-outline</v-icon>
|
||||||
|
<p class="text-body-2">This identity does not match any segments.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="d-flex flex-wrap ga-2" data-testid="segments-list">
|
||||||
|
<v-chip
|
||||||
|
v-for="segment in identitySegments"
|
||||||
|
:key="segment.id"
|
||||||
|
color="primary"
|
||||||
|
variant="tonal"
|
||||||
|
size="small"
|
||||||
|
:data-testid="`segment-chip-${segment.id}`"
|
||||||
|
>
|
||||||
|
{{ segment.name }}
|
||||||
|
</v-chip>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</v-tabs-window-item>
|
||||||
</v-tabs-window>
|
</v-tabs-window>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
@@ -205,10 +304,11 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue';
|
import { ref, computed, watch } from 'vue';
|
||||||
import { getIdentityFeatureStates, setIdentityFeatureState, deleteIdentity, deleteIdentityFeatureState } from '@/api/identities';
|
import { getIdentityFeatureStates, setIdentityFeatureState, deleteIdentity, deleteIdentityFeatureState, upsertIdentityTrait, deleteIdentityTrait } from '@/api/identities';
|
||||||
import { listFeatures } from '@/api/features';
|
import { listFeatures } from '@/api/features';
|
||||||
|
import { listIdentitySegments } from '@/api/featureSegments';
|
||||||
import { useContextStore } from '@/stores/context';
|
import { useContextStore } from '@/stores/context';
|
||||||
import type { IdentityResponse, FeatureResponse, FeatureStateResponse } from '@/types/api';
|
import type { IdentityResponse, FeatureResponse, FeatureStateResponse, SegmentSummaryResponse, TraitResponse } from '@/types/api';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
modelValue: boolean;
|
modelValue: boolean;
|
||||||
@@ -228,6 +328,14 @@ const activeTab = ref('traits');
|
|||||||
const isDeleting = ref(false);
|
const isDeleting = ref(false);
|
||||||
const traitsError = ref<string | null>(null);
|
const traitsError = ref<string | null>(null);
|
||||||
|
|
||||||
|
// Local editable copy of traits
|
||||||
|
const editedTraits = ref<TraitResponse[]>([]);
|
||||||
|
const savingTraitKey = ref<string | null>(null);
|
||||||
|
const deletingTraitKey = ref<string | null>(null);
|
||||||
|
const newTraitKey = ref('');
|
||||||
|
const newTraitValue = ref('');
|
||||||
|
const isAddingTrait = ref(false);
|
||||||
|
|
||||||
const overrides = ref<FeatureStateResponse[]>([]);
|
const overrides = ref<FeatureStateResponse[]>([]);
|
||||||
const allFeatures = ref<FeatureResponse[]>([]);
|
const allFeatures = ref<FeatureResponse[]>([]);
|
||||||
const isLoadingOverrides = ref(false);
|
const isLoadingOverrides = ref(false);
|
||||||
@@ -238,6 +346,10 @@ const newOverrideFeatureId = ref<number | null>(null);
|
|||||||
const newOverrideEnabled = ref(false);
|
const newOverrideEnabled = ref(false);
|
||||||
const newOverrideValue = ref('');
|
const newOverrideValue = ref('');
|
||||||
|
|
||||||
|
const identitySegments = ref<SegmentSummaryResponse[]>([]);
|
||||||
|
const isLoadingSegments = ref(false);
|
||||||
|
const segmentsError = ref<string | null>(null);
|
||||||
|
|
||||||
// Map featureId → name for display in overrides table
|
// Map featureId → name for display in overrides table
|
||||||
const featureNameMap = computed(() => new Map(allFeatures.value.map((f) => [f.id, f.name])));
|
const featureNameMap = computed(() => new Map(allFeatures.value.map((f) => [f.id, f.name])));
|
||||||
|
|
||||||
@@ -247,6 +359,22 @@ const availableFeatures = computed(() => {
|
|||||||
return allFeatures.value.filter((f) => !overriddenIds.has(f.id));
|
return allFeatures.value.filter((f) => !overriddenIds.has(f.id));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function loadSegmentsData(): Promise<void> {
|
||||||
|
if (!props.identity) return;
|
||||||
|
const envApiKey = contextStore.currentEnvironment?.apiKey;
|
||||||
|
if (!envApiKey) return;
|
||||||
|
|
||||||
|
isLoadingSegments.value = true;
|
||||||
|
segmentsError.value = null;
|
||||||
|
try {
|
||||||
|
identitySegments.value = await listIdentitySegments(envApiKey, props.identity.id);
|
||||||
|
} catch {
|
||||||
|
segmentsError.value = 'Failed to load segments. Please try again.';
|
||||||
|
} finally {
|
||||||
|
isLoadingSegments.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadOverridesData(): Promise<void> {
|
async function loadOverridesData(): Promise<void> {
|
||||||
if (!props.identity) return;
|
if (!props.identity) return;
|
||||||
const envApiKey = contextStore.currentEnvironment?.apiKey;
|
const envApiKey = contextStore.currentEnvironment?.apiKey;
|
||||||
@@ -276,11 +404,17 @@ watch(
|
|||||||
activeTab.value = 'traits';
|
activeTab.value = 'traits';
|
||||||
traitsError.value = null;
|
traitsError.value = null;
|
||||||
overridesError.value = null;
|
overridesError.value = null;
|
||||||
|
segmentsError.value = null;
|
||||||
overrides.value = [];
|
overrides.value = [];
|
||||||
|
identitySegments.value = [];
|
||||||
newOverrideFeatureId.value = null;
|
newOverrideFeatureId.value = null;
|
||||||
newOverrideEnabled.value = false;
|
newOverrideEnabled.value = false;
|
||||||
newOverrideValue.value = '';
|
newOverrideValue.value = '';
|
||||||
|
newTraitKey.value = '';
|
||||||
|
newTraitValue.value = '';
|
||||||
|
editedTraits.value = props.identity ? props.identity.traits.map((t: TraitResponse) => ({ ...t })) : [];
|
||||||
loadOverridesData();
|
loadOverridesData();
|
||||||
|
loadSegmentsData();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
@@ -355,6 +489,64 @@ async function onAddOverride(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onSaveTrait(key: string, value: string): Promise<void> {
|
||||||
|
if (!props.identity) return;
|
||||||
|
const envApiKey = contextStore.currentEnvironment?.apiKey;
|
||||||
|
if (!envApiKey) return;
|
||||||
|
savingTraitKey.value = key;
|
||||||
|
traitsError.value = null;
|
||||||
|
try {
|
||||||
|
await upsertIdentityTrait(envApiKey, props.identity.id, key, { value });
|
||||||
|
const idx = editedTraits.value.findIndex((t: TraitResponse) => t.key === key);
|
||||||
|
if (idx >= 0) editedTraits.value[idx] = { key, value };
|
||||||
|
} catch {
|
||||||
|
traitsError.value = 'Failed to save trait. Please try again.';
|
||||||
|
} finally {
|
||||||
|
savingTraitKey.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDeleteTrait(key: string): Promise<void> {
|
||||||
|
if (!props.identity) return;
|
||||||
|
const envApiKey = contextStore.currentEnvironment?.apiKey;
|
||||||
|
if (!envApiKey) return;
|
||||||
|
deletingTraitKey.value = key;
|
||||||
|
traitsError.value = null;
|
||||||
|
try {
|
||||||
|
await deleteIdentityTrait(envApiKey, props.identity.id, key);
|
||||||
|
editedTraits.value = editedTraits.value.filter((t: TraitResponse) => t.key !== key);
|
||||||
|
} catch {
|
||||||
|
traitsError.value = 'Failed to delete trait. Please try again.';
|
||||||
|
} finally {
|
||||||
|
deletingTraitKey.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onAddTrait(): Promise<void> {
|
||||||
|
const key = newTraitKey.value.trim();
|
||||||
|
const value = newTraitValue.value;
|
||||||
|
if (!key || !props.identity) return;
|
||||||
|
const envApiKey = contextStore.currentEnvironment?.apiKey;
|
||||||
|
if (!envApiKey) return;
|
||||||
|
isAddingTrait.value = true;
|
||||||
|
traitsError.value = null;
|
||||||
|
try {
|
||||||
|
await upsertIdentityTrait(envApiKey, props.identity.id, key, { value });
|
||||||
|
const existing = editedTraits.value.findIndex((t: TraitResponse) => t.key === key);
|
||||||
|
if (existing >= 0) {
|
||||||
|
editedTraits.value[existing] = { key, value };
|
||||||
|
} else {
|
||||||
|
editedTraits.value = [...editedTraits.value, { key, value }];
|
||||||
|
}
|
||||||
|
newTraitKey.value = '';
|
||||||
|
newTraitValue.value = '';
|
||||||
|
} catch {
|
||||||
|
traitsError.value = 'Failed to add trait. Please try again.';
|
||||||
|
} finally {
|
||||||
|
isAddingTrait.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function onDeleteIdentity(): Promise<void> {
|
async function onDeleteIdentity(): Promise<void> {
|
||||||
if (!props.identity) return;
|
if (!props.identity) return;
|
||||||
const envApiKey = contextStore.currentEnvironment?.apiKey;
|
const envApiKey = contextStore.currentEnvironment?.apiKey;
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { createPinia } from 'pinia';
|
|||||||
import App from './App.vue';
|
import App from './App.vue';
|
||||||
import router from './router';
|
import router from './router';
|
||||||
import vuetify from './plugins/vuetify';
|
import vuetify from './plugins/vuetify';
|
||||||
import '@mdi/font/css/materialdesignicons.css';
|
|
||||||
import { useAuthStore } from './stores/auth';
|
import { useAuthStore } from './stores/auth';
|
||||||
import { useContextStore } from './stores/context';
|
import { useContextStore } from './stores/context';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'vuetify/styles';
|
import 'vuetify/styles';
|
||||||
import { createVuetify } from 'vuetify';
|
import { createVuetify } from 'vuetify';
|
||||||
import { aliases, mdi } from 'vuetify/iconsets/mdi';
|
import { aliases, mdi } from 'vuetify/iconsets/mdi-svg';
|
||||||
|
|
||||||
export default createVuetify({
|
export default createVuetify({
|
||||||
icons: {
|
icons: {
|
||||||
|
|||||||
@@ -1,83 +1,71 @@
|
|||||||
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
|
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
|
||||||
import { getAccessToken } from '@/api/client';
|
import { getAccessToken } from '@/api/client';
|
||||||
import AppLayout from '@/components/AppLayout.vue';
|
|
||||||
import LoginView from '@/views/LoginView.vue';
|
|
||||||
import RegisterView from '@/views/RegisterView.vue';
|
|
||||||
import DashboardView from '@/views/DashboardView.vue';
|
|
||||||
import FeaturesView from '@/views/FeaturesView.vue';
|
|
||||||
import SegmentsView from '@/views/SegmentsView.vue';
|
|
||||||
import IdentitiesView from '@/views/IdentitiesView.vue';
|
|
||||||
import AuditLogsView from '@/views/AuditLogsView.vue';
|
|
||||||
import EnvironmentsView from '@/views/EnvironmentsView.vue';
|
|
||||||
import ProjectsView from '@/views/ProjectsView.vue';
|
|
||||||
import ProfileView from '@/views/ProfileView.vue';
|
|
||||||
import SettingsView from '@/views/SettingsView.vue';
|
|
||||||
|
|
||||||
const routes: RouteRecordRaw[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
// ─── Public routes (no layout) ────────────────────────────────────────────
|
// ─── Public routes (no layout) ────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
path: '/login',
|
path: '/login',
|
||||||
name: 'Login',
|
name: 'Login',
|
||||||
component: LoginView,
|
component: () => import('@/views/LoginView.vue'),
|
||||||
meta: { public: true },
|
meta: { public: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/register',
|
path: '/register',
|
||||||
name: 'Register',
|
name: 'Register',
|
||||||
component: RegisterView,
|
component: () => import('@/views/RegisterView.vue'),
|
||||||
meta: { public: true },
|
meta: { public: true },
|
||||||
},
|
},
|
||||||
|
|
||||||
// ─── Authenticated routes (wrapped in AppLayout) ───────────────────────────
|
// ─── Authenticated routes (wrapped in AppLayout) ───────────────────────────
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
component: AppLayout,
|
component: () => import('@/components/AppLayout.vue'),
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
name: 'Dashboard',
|
name: 'Dashboard',
|
||||||
component: DashboardView,
|
component: () => import('@/views/DashboardView.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'features',
|
path: 'features',
|
||||||
name: 'Features',
|
name: 'Features',
|
||||||
component: FeaturesView,
|
component: () => import('@/views/FeaturesView.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'segments',
|
path: 'segments',
|
||||||
name: 'Segments',
|
name: 'Segments',
|
||||||
component: SegmentsView,
|
component: () => import('@/views/SegmentsView.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'identities',
|
path: 'identities',
|
||||||
name: 'Identities',
|
name: 'Identities',
|
||||||
component: IdentitiesView,
|
component: () => import('@/views/IdentitiesView.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'audit-logs',
|
path: 'audit-logs',
|
||||||
name: 'AuditLogs',
|
name: 'AuditLogs',
|
||||||
component: AuditLogsView,
|
component: () => import('@/views/AuditLogsView.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'environments',
|
path: 'environments',
|
||||||
name: 'Environments',
|
name: 'Environments',
|
||||||
component: EnvironmentsView,
|
component: () => import('@/views/EnvironmentsView.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'projects',
|
path: 'projects',
|
||||||
name: 'Projects',
|
name: 'Projects',
|
||||||
component: ProjectsView,
|
component: () => import('@/views/ProjectsView.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'profile',
|
path: 'profile',
|
||||||
name: 'Profile',
|
name: 'Profile',
|
||||||
component: ProfileView,
|
component: () => import('@/views/ProfileView.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'settings',
|
path: 'settings',
|
||||||
name: 'Settings',
|
name: 'Settings',
|
||||||
component: SettingsView,
|
component: () => import('@/views/SettingsView.vue'),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -54,7 +54,11 @@ export interface UpdateOrganizationRequest {
|
|||||||
|
|
||||||
export interface OrganizationMemberResponse {
|
export interface OrganizationMemberResponse {
|
||||||
userId: number;
|
userId: number;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
role: 'Admin' | 'User';
|
role: 'Admin' | 'User';
|
||||||
|
lastLoginAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InviteUserRequest {
|
export interface InviteUserRequest {
|
||||||
@@ -268,6 +272,35 @@ export interface UpdateSegmentRequest {
|
|||||||
rules: SegmentRule[];
|
rules: SegmentRule[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SegmentSummaryResponse {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeatureSegmentResponse {
|
||||||
|
id: number;
|
||||||
|
featureId: number;
|
||||||
|
segmentId: number;
|
||||||
|
segmentName: string;
|
||||||
|
environmentId: number;
|
||||||
|
priority: number;
|
||||||
|
enabled: boolean | null;
|
||||||
|
value: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateFeatureSegmentRequest {
|
||||||
|
segmentId: number;
|
||||||
|
priority: number;
|
||||||
|
enabled: boolean;
|
||||||
|
value: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateFeatureSegmentRequest {
|
||||||
|
priority: number;
|
||||||
|
enabled: boolean;
|
||||||
|
value: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Tags ─────────────────────────────────────────────────────────────────────
|
// ─── Tags ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface TagResponse {
|
export interface TagResponse {
|
||||||
@@ -351,7 +384,11 @@ export interface WebhookDeliveryLogResponse {
|
|||||||
|
|
||||||
export interface TraitResponse {
|
export interface TraitResponse {
|
||||||
key: string;
|
key: string;
|
||||||
value: string | number | boolean | null;
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpsertTraitRequest {
|
||||||
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IdentityResponse {
|
export interface IdentityResponse {
|
||||||
@@ -362,6 +399,10 @@ export interface IdentityResponse {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateIdentityRequest {
|
||||||
|
identifier: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TraitRequest {
|
export interface TraitRequest {
|
||||||
traitKey: string;
|
traitKey: string;
|
||||||
traitValue: string | number | boolean | null;
|
traitValue: string | number | boolean | null;
|
||||||
|
|||||||
@@ -89,6 +89,34 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- Segment overrides -->
|
||||||
|
<template #item.segments="{ item }: { item: FeatureResponse }">
|
||||||
|
<div v-if="!contextStore.currentEnvironment" class="text-caption text-medium-emphasis">—</div>
|
||||||
|
<template v-else>
|
||||||
|
<div
|
||||||
|
v-if="(featureSegmentsMap.get(item.id) ?? []).length > 0"
|
||||||
|
class="d-flex flex-wrap align-center ga-1 py-1"
|
||||||
|
>
|
||||||
|
<v-chip
|
||||||
|
v-for="seg in (featureSegmentsMap.get(item.id) ?? []).slice(0, 5)"
|
||||||
|
:key="seg.id"
|
||||||
|
size="x-small"
|
||||||
|
variant="tonal"
|
||||||
|
color="secondary"
|
||||||
|
:data-testid="`segment-chip-${item.id}-${seg.id}`"
|
||||||
|
>
|
||||||
|
{{ seg.segmentName }}
|
||||||
|
</v-chip>
|
||||||
|
<span
|
||||||
|
v-if="(featureSegmentsMap.get(item.id) ?? []).length > 5"
|
||||||
|
class="text-caption text-medium-emphasis"
|
||||||
|
:title="`${(featureSegmentsMap.get(item.id) ?? []).length - 5} more`"
|
||||||
|
>…</span>
|
||||||
|
</div>
|
||||||
|
<span v-else class="text-caption text-medium-emphasis">—</span>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Created at -->
|
<!-- Created at -->
|
||||||
<template #item.createdAt="{ item }: { item: FeatureResponse }">
|
<template #item.createdAt="{ item }: { item: FeatureResponse }">
|
||||||
<span class="text-caption text-medium-emphasis">
|
<span class="text-caption text-medium-emphasis">
|
||||||
@@ -96,17 +124,6 @@
|
|||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Row actions -->
|
|
||||||
<template #item.actions="{ item }: { item: FeatureResponse }">
|
|
||||||
<v-btn
|
|
||||||
icon="mdi-pencil-outline"
|
|
||||||
size="small"
|
|
||||||
variant="text"
|
|
||||||
:data-testid="`edit-feature-${item.id}`"
|
|
||||||
@click.stop="openEditDialog(item)"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Empty state -->
|
<!-- Empty state -->
|
||||||
<template #no-data>
|
<template #no-data>
|
||||||
<div class="text-center py-8">
|
<div class="text-center py-8">
|
||||||
@@ -121,7 +138,6 @@
|
|||||||
<!-- Create / Edit dialog -->
|
<!-- Create / Edit dialog -->
|
||||||
<FeatureDialog
|
<FeatureDialog
|
||||||
v-model="showDialog"
|
v-model="showDialog"
|
||||||
:feature="editingFeature"
|
|
||||||
@saved="onFeatureSaved"
|
@saved="onFeatureSaved"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -154,16 +170,18 @@
|
|||||||
import { ref, computed, watch } from 'vue';
|
import { ref, computed, watch } from 'vue';
|
||||||
import { listFeatures } from '@/api/features';
|
import { listFeatures } from '@/api/features';
|
||||||
import { listFeatureStates, patchFeatureState } from '@/api/featureStates';
|
import { listFeatureStates, patchFeatureState } from '@/api/featureStates';
|
||||||
|
import { listFeatureSegments } from '@/api/featureSegments';
|
||||||
import { useContextStore } from '@/stores/context';
|
import { useContextStore } from '@/stores/context';
|
||||||
import FeatureDialog from '@/components/features/FeatureDialog.vue';
|
import FeatureDialog from '@/components/features/FeatureDialog.vue';
|
||||||
import FeatureDetail from '@/components/features/FeatureDetail.vue';
|
import FeatureDetail from '@/components/features/FeatureDetail.vue';
|
||||||
import type { FeatureResponse, FeatureStateResponse } from '@/types/api';
|
import type { FeatureResponse, FeatureStateResponse, FeatureSegmentResponse } from '@/types/api';
|
||||||
|
|
||||||
const contextStore = useContextStore();
|
const contextStore = useContextStore();
|
||||||
|
|
||||||
// ─── State ────────────────────────────────────────────────────────────────────
|
// ─── State ────────────────────────────────────────────────────────────────────
|
||||||
const features = ref<FeatureResponse[]>([]);
|
const features = ref<FeatureResponse[]>([]);
|
||||||
const featureStateMap = ref<Map<number, FeatureStateResponse>>(new Map());
|
const featureStateMap = ref<Map<number, FeatureStateResponse>>(new Map());
|
||||||
|
const featureSegmentsMap = ref<Map<number, FeatureSegmentResponse[]>>(new Map());
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
const search = ref('');
|
const search = ref('');
|
||||||
const togglingFeatureId = ref<number | null>(null);
|
const togglingFeatureId = ref<number | null>(null);
|
||||||
@@ -179,7 +197,6 @@ function showError(message: string): void {
|
|||||||
|
|
||||||
// Dialog / detail state
|
// Dialog / detail state
|
||||||
const showDialog = ref(false);
|
const showDialog = ref(false);
|
||||||
const editingFeature = ref<FeatureResponse | null>(null);
|
|
||||||
const showDetail = ref(false);
|
const showDetail = ref(false);
|
||||||
const selectedFeature = ref<FeatureResponse | null>(null);
|
const selectedFeature = ref<FeatureResponse | null>(null);
|
||||||
|
|
||||||
@@ -192,8 +209,8 @@ const headers = [
|
|||||||
{ title: 'Name', key: 'name', sortable: true },
|
{ title: 'Name', key: 'name', sortable: true },
|
||||||
{ title: 'Type', key: 'type', sortable: true, width: '140' },
|
{ title: 'Type', key: 'type', sortable: true, width: '140' },
|
||||||
{ title: 'Enabled', key: 'enabled', sortable: false, width: '100' },
|
{ title: 'Enabled', key: 'enabled', sortable: false, width: '100' },
|
||||||
|
{ title: 'Segments', key: 'segments', sortable: false },
|
||||||
{ title: 'Created', key: 'createdAt', sortable: true, width: '130' },
|
{ title: 'Created', key: 'createdAt', sortable: true, width: '130' },
|
||||||
{ title: '', key: 'actions', sortable: false, width: '50', align: 'end' as const },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// ─── Computed ─────────────────────────────────────────────────────────────────
|
// ─── Computed ─────────────────────────────────────────────────────────────────
|
||||||
@@ -239,22 +256,36 @@ async function loadFeatureStates(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadFeatureSegments(): Promise<void> {
|
||||||
|
const envApiKey = contextStore.currentEnvironment?.apiKey;
|
||||||
|
if (!envApiKey || features.value.length === 0) {
|
||||||
|
featureSegmentsMap.value = new Map();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
features.value.map((f) => listFeatureSegments(envApiKey, f.id).then((segs) => [f.id, segs] as const)),
|
||||||
|
);
|
||||||
|
const map = new Map<number, FeatureSegmentResponse[]>();
|
||||||
|
for (const result of results) {
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
const [featureId, segs] = result.value;
|
||||||
|
map.set(featureId, segs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
featureSegmentsMap.value = map;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadData(): Promise<void> {
|
async function loadData(): Promise<void> {
|
||||||
await Promise.all([loadFeatures(), loadFeatureStates()]);
|
await loadFeatures();
|
||||||
|
await Promise.all([loadFeatureStates(), loadFeatureSegments()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reload when project or environment changes
|
// Reload when project or environment changes
|
||||||
watch(() => contextStore.currentProject, loadData, { immediate: true });
|
watch(() => contextStore.currentProject, loadData, { immediate: true });
|
||||||
watch(() => contextStore.currentEnvironment, loadFeatureStates);
|
watch(() => contextStore.currentEnvironment, () => Promise.all([loadFeatureStates(), loadFeatureSegments()]));
|
||||||
|
|
||||||
// ─── Actions ──────────────────────────────────────────────────────────────────
|
// ─── Actions ──────────────────────────────────────────────────────────────────
|
||||||
function openCreateDialog(): void {
|
function openCreateDialog(): void {
|
||||||
editingFeature.value = null;
|
|
||||||
showDialog.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEditDialog(feature: FeatureResponse): void {
|
|
||||||
editingFeature.value = feature;
|
|
||||||
showDialog.value = true;
|
showDialog.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,15 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="d-flex align-center justify-space-between mb-4">
|
<div class="d-flex align-center justify-space-between mb-4">
|
||||||
<h1 class="text-h5 font-weight-bold">Identities</h1>
|
<h1 class="text-h5 font-weight-bold">Identities</h1>
|
||||||
|
<v-btn
|
||||||
|
v-if="contextStore.currentEnvironment"
|
||||||
|
color="primary"
|
||||||
|
prepend-icon="mdi-plus"
|
||||||
|
data-testid="create-identity-btn"
|
||||||
|
@click="showCreateDialog = true"
|
||||||
|
>
|
||||||
|
Create Identity
|
||||||
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- No environment selected -->
|
<!-- No environment selected -->
|
||||||
@@ -58,7 +67,7 @@
|
|||||||
<!-- Row actions -->
|
<!-- Row actions -->
|
||||||
<template #item.actions="{ item }: { item: IdentityResponse }">
|
<template #item.actions="{ item }: { item: IdentityResponse }">
|
||||||
<v-btn
|
<v-btn
|
||||||
icon="mdi-eye-outline"
|
icon="mdi-pencil-outline"
|
||||||
size="small"
|
size="small"
|
||||||
variant="text"
|
variant="text"
|
||||||
:data-testid="`view-identity-${item.id}`"
|
:data-testid="`view-identity-${item.id}`"
|
||||||
@@ -84,6 +93,50 @@
|
|||||||
@deleted="onIdentityDeleted"
|
@deleted="onIdentityDeleted"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Create identity dialog -->
|
||||||
|
<v-dialog v-model="showCreateDialog" max-width="440" @after-leave="onCreateDialogClosed">
|
||||||
|
<v-card rounded="lg">
|
||||||
|
<v-card-title class="pa-6 pb-3">Create Identity</v-card-title>
|
||||||
|
<v-card-text class="pa-6 pt-0">
|
||||||
|
<v-alert
|
||||||
|
v-if="createError"
|
||||||
|
type="error"
|
||||||
|
variant="tonal"
|
||||||
|
density="compact"
|
||||||
|
closable
|
||||||
|
class="mb-4"
|
||||||
|
@click:close="createError = null"
|
||||||
|
>
|
||||||
|
{{ createError }}
|
||||||
|
</v-alert>
|
||||||
|
<v-text-field
|
||||||
|
v-model="newIdentifier"
|
||||||
|
label="Identifier"
|
||||||
|
variant="outlined"
|
||||||
|
density="comfortable"
|
||||||
|
autofocus
|
||||||
|
hide-details="auto"
|
||||||
|
placeholder="e.g. user-123 or alice@example.com"
|
||||||
|
data-testid="create-identity-identifier-input"
|
||||||
|
@keydown.enter="onCreateConfirm"
|
||||||
|
/>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions class="pa-6 pt-0 d-flex justify-end ga-2">
|
||||||
|
<v-btn variant="text" @click="showCreateDialog = false">Cancel</v-btn>
|
||||||
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
:disabled="!newIdentifier.trim()"
|
||||||
|
:loading="isCreating"
|
||||||
|
data-testid="create-identity-confirm-btn"
|
||||||
|
@click="onCreateConfirm"
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
<!-- Error snackbar -->
|
<!-- Error snackbar -->
|
||||||
<v-snackbar
|
<v-snackbar
|
||||||
v-model="showErrorSnackbar"
|
v-model="showErrorSnackbar"
|
||||||
@@ -101,7 +154,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue';
|
import { ref, computed, watch } from 'vue';
|
||||||
import { listIdentities } from '@/api/identities';
|
import { listIdentities, createIdentity } from '@/api/identities';
|
||||||
import { useContextStore } from '@/stores/context';
|
import { useContextStore } from '@/stores/context';
|
||||||
import IdentityDetail from '@/components/identities/IdentityDetail.vue';
|
import IdentityDetail from '@/components/identities/IdentityDetail.vue';
|
||||||
import type { IdentityResponse } from '@/types/api';
|
import type { IdentityResponse } from '@/types/api';
|
||||||
@@ -117,6 +170,11 @@ const snackbarMessage = ref('');
|
|||||||
const showDetail = ref(false);
|
const showDetail = ref(false);
|
||||||
const selectedIdentity = ref<IdentityResponse | null>(null);
|
const selectedIdentity = ref<IdentityResponse | null>(null);
|
||||||
|
|
||||||
|
const showCreateDialog = ref(false);
|
||||||
|
const newIdentifier = ref('');
|
||||||
|
const isCreating = ref(false);
|
||||||
|
const createError = ref<string | null>(null);
|
||||||
|
|
||||||
const headers = [
|
const headers = [
|
||||||
{ title: 'Identifier', key: 'identifier', sortable: true },
|
{ title: 'Identifier', key: 'identifier', sortable: true },
|
||||||
{ title: 'Traits', key: 'traits', sortable: false, width: '80' },
|
{ title: 'Traits', key: 'traits', sortable: false, width: '80' },
|
||||||
@@ -164,6 +222,30 @@ function onRowClick(_event: Event, { item }: { item: IdentityResponse }): void {
|
|||||||
openDetail(item);
|
openDetail(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onCreateConfirm(): Promise<void> {
|
||||||
|
const envApiKey = contextStore.currentEnvironment?.apiKey;
|
||||||
|
if (!envApiKey || !newIdentifier.value.trim()) return;
|
||||||
|
isCreating.value = true;
|
||||||
|
createError.value = null;
|
||||||
|
try {
|
||||||
|
const created = await createIdentity(envApiKey, { identifier: newIdentifier.value.trim() });
|
||||||
|
identities.value.unshift(created);
|
||||||
|
showCreateDialog.value = false;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const status = (err as { response?: { status?: number } })?.response?.status;
|
||||||
|
createError.value = status === 409
|
||||||
|
? 'An identity with this identifier already exists in this environment.'
|
||||||
|
: 'Failed to create identity. Please try again.';
|
||||||
|
} finally {
|
||||||
|
isCreating.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCreateDialogClosed(): void {
|
||||||
|
newIdentifier.value = '';
|
||||||
|
createError.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
function onIdentityDeleted(identityId: number): void {
|
function onIdentityDeleted(identityId: number): void {
|
||||||
identities.value = identities.value.filter((i) => i.id !== identityId);
|
identities.value = identities.value.filter((i) => i.id !== identityId);
|
||||||
showDetail.value = false;
|
showDetail.value = false;
|
||||||
|
|||||||
@@ -51,21 +51,23 @@
|
|||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<template #item.actions="{ item }: { item: SegmentResponse }">
|
<template #item.actions="{ item }: { item: SegmentResponse }">
|
||||||
<v-btn
|
<div class="d-flex align-center">
|
||||||
icon="mdi-pencil-outline"
|
<v-btn
|
||||||
size="small"
|
icon="mdi-pencil-outline"
|
||||||
variant="text"
|
size="small"
|
||||||
:data-testid="`edit-segment-${item.id}`"
|
variant="text"
|
||||||
@click="openEditDialog(item)"
|
:data-testid="`edit-segment-${item.id}`"
|
||||||
/>
|
@click="openEditDialog(item)"
|
||||||
<v-btn
|
/>
|
||||||
icon="mdi-trash-can-outline"
|
<v-btn
|
||||||
size="small"
|
icon="mdi-trash-can-outline"
|
||||||
variant="text"
|
size="small"
|
||||||
color="error"
|
variant="text"
|
||||||
:data-testid="`delete-segment-${item.id}`"
|
color="error"
|
||||||
@click="openDeleteConfirm(item)"
|
:data-testid="`delete-segment-${item.id}`"
|
||||||
/>
|
@click="openDeleteConfirm(item)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Empty state -->
|
<!-- Empty state -->
|
||||||
|
|||||||
@@ -57,11 +57,23 @@
|
|||||||
<v-card-title class="text-body-1 font-weight-medium pa-4 pb-2">Members</v-card-title>
|
<v-card-title class="text-body-1 font-weight-medium pa-4 pb-2">Members</v-card-title>
|
||||||
<v-card-text class="pa-4 pt-0">
|
<v-card-text class="pa-4 pt-0">
|
||||||
<v-table v-if="orgMembers.length > 0" density="compact" class="mb-3" data-testid="members-table">
|
<v-table v-if="orgMembers.length > 0" density="compact" class="mb-3" data-testid="members-table">
|
||||||
<thead><tr><th>User ID</th><th>Role</th><th style="width:60px"></th></tr></thead>
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>First Name</th>
|
||||||
|
<th>Last Name</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th style="width:100px">Role</th>
|
||||||
|
<th style="width:140px">Last Login</th>
|
||||||
|
<th style="width:60px"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="member in orgMembers" :key="member.userId" :data-testid="`member-row-${member.userId}`">
|
<tr v-for="member in orgMembers" :key="member.userId" :data-testid="`member-row-${member.userId}`">
|
||||||
<td class="text-body-2">{{ member.userId }}</td>
|
<td class="text-body-2">{{ member.firstName }}</td>
|
||||||
|
<td class="text-body-2">{{ member.lastName }}</td>
|
||||||
|
<td class="text-body-2">{{ member.email }}</td>
|
||||||
<td><v-chip size="x-small" :color="member.role === 'Admin' ? 'primary' : 'default'" variant="tonal">{{ member.role }}</v-chip></td>
|
<td><v-chip size="x-small" :color="member.role === 'Admin' ? 'primary' : 'default'" variant="tonal">{{ member.role }}</v-chip></td>
|
||||||
|
<td class="text-caption text-medium-emphasis">{{ member.lastLoginAt ? formatDate(member.lastLoginAt) : '—' }}</td>
|
||||||
<td>
|
<td>
|
||||||
<v-btn icon="mdi-trash-can-outline" size="x-small" variant="text" color="error"
|
<v-btn icon="mdi-trash-can-outline" size="x-small" variant="text" color="error"
|
||||||
:data-testid="`remove-member-${member.userId}`" @click="onRemoveMember(member.userId)" />
|
:data-testid="`remove-member-${member.userId}`" @click="onRemoveMember(member.userId)" />
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import { setActivePinia, createPinia } from 'pinia';
|
|||||||
import { createRouter, createMemoryHistory } from 'vue-router';
|
import { createRouter, createMemoryHistory } from 'vue-router';
|
||||||
import IdentityDetail from '@/components/identities/IdentityDetail.vue';
|
import IdentityDetail from '@/components/identities/IdentityDetail.vue';
|
||||||
import { useContextStore } from '@/stores/context';
|
import { useContextStore } from '@/stores/context';
|
||||||
import type { EnvironmentResponse, IdentityResponse, FeatureResponse, FeatureStateResponse, ProjectResponse } from '@/types/api';
|
import type { EnvironmentResponse, IdentityResponse, FeatureResponse, FeatureStateResponse, ProjectResponse, SegmentSummaryResponse } from '@/types/api';
|
||||||
|
|
||||||
jest.mock('@/api/identities');
|
jest.mock('@/api/identities');
|
||||||
jest.mock('@/api/features');
|
jest.mock('@/api/features');
|
||||||
|
jest.mock('@/api/featureSegments');
|
||||||
jest.mock('@/api/client', () => ({
|
jest.mock('@/api/client', () => ({
|
||||||
setAuthTokens: jest.fn(),
|
setAuthTokens: jest.fn(),
|
||||||
clearAuthTokens: jest.fn(),
|
clearAuthTokens: jest.fn(),
|
||||||
@@ -16,6 +17,7 @@ jest.mock('@/api/client', () => ({
|
|||||||
|
|
||||||
import * as identitiesApi from '@/api/identities';
|
import * as identitiesApi from '@/api/identities';
|
||||||
import * as featuresApi from '@/api/features';
|
import * as featuresApi from '@/api/features';
|
||||||
|
import * as featureSegmentsApi from '@/api/featureSegments';
|
||||||
|
|
||||||
const mockProject: ProjectResponse = {
|
const mockProject: ProjectResponse = {
|
||||||
id: 10, name: 'Website', organizationId: 1, hideDisabledFlags: false, createdAt: '2026-01-01T00:00:00Z',
|
id: 10, name: 'Website', organizationId: 1, hideDisabledFlags: false, createdAt: '2026-01-01T00:00:00Z',
|
||||||
@@ -38,6 +40,10 @@ const mockOverride: FeatureStateResponse = {
|
|||||||
id: 500, featureId: 10, environmentId: 100, identityId: 1, featureSegmentId: null,
|
id: 500, featureId: 10, environmentId: 100, identityId: 1, featureSegmentId: null,
|
||||||
enabled: true, value: null, createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z',
|
enabled: true, value: null, createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z',
|
||||||
};
|
};
|
||||||
|
const mockSegments: SegmentSummaryResponse[] = [
|
||||||
|
{ id: 1, name: 'Pro Users' },
|
||||||
|
{ id: 2, name: 'US Customers' },
|
||||||
|
];
|
||||||
|
|
||||||
const dialogStub = { template: '<div><slot /></div>' };
|
const dialogStub = { template: '<div><slot /></div>' };
|
||||||
|
|
||||||
@@ -60,6 +66,7 @@ describe('IdentityDetail', () => {
|
|||||||
|
|
||||||
jest.mocked(identitiesApi.getIdentityFeatureStates).mockResolvedValue([]);
|
jest.mocked(identitiesApi.getIdentityFeatureStates).mockResolvedValue([]);
|
||||||
jest.mocked(featuresApi.listFeatures).mockResolvedValue({ count: 2, next: null, previous: null, results: mockFeatures });
|
jest.mocked(featuresApi.listFeatures).mockResolvedValue({ count: 2, next: null, previous: null, results: mockFeatures });
|
||||||
|
jest.mocked(featureSegmentsApi.listIdentitySegments).mockResolvedValue([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('WhenOpenedWithIdentity', () => {
|
describe('WhenOpenedWithIdentity', () => {
|
||||||
@@ -75,11 +82,12 @@ describe('IdentityDetail', () => {
|
|||||||
expect(wrapper.text()).toContain('user-alice');
|
expect(wrapper.text()).toContain('user-alice');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ThenTraitsAndOverridesTabsArePresent', async () => {
|
it('ThenTraitsOverridesAndSegmentsTabsArePresent', async () => {
|
||||||
const wrapper = mountDetail();
|
const wrapper = mountDetail();
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
expect(wrapper.find('[data-testid="tab-traits"]').exists()).toBe(true);
|
expect(wrapper.find('[data-testid="tab-traits"]').exists()).toBe(true);
|
||||||
expect(wrapper.find('[data-testid="tab-overrides"]').exists()).toBe(true);
|
expect(wrapper.find('[data-testid="tab-overrides"]').exists()).toBe(true);
|
||||||
|
expect(wrapper.find('[data-testid="tab-segments"]').exists()).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -96,14 +104,129 @@ describe('IdentityDetail', () => {
|
|||||||
expect(wrapper.find('[data-testid="trait-row-plan"]').exists()).toBe(true);
|
expect(wrapper.find('[data-testid="trait-row-plan"]').exists()).toBe(true);
|
||||||
expect(wrapper.find('[data-testid="trait-row-country"]').exists()).toBe(true);
|
expect(wrapper.find('[data-testid="trait-row-country"]').exists()).toBe(true);
|
||||||
expect(wrapper.text()).toContain('plan');
|
expect(wrapper.text()).toContain('plan');
|
||||||
expect(wrapper.text()).toContain('pro');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ThenEmptyMessageIsShownWhenNoTraits', async () => {
|
it('ThenNoTraitsMessageIsShownWhenNoTraits', async () => {
|
||||||
const identityWithNoTraits: IdentityResponse = { ...mockIdentity, traits: [] };
|
const identityWithNoTraits: IdentityResponse = { ...mockIdentity, traits: [] };
|
||||||
const wrapper = mountDetail({ identity: identityWithNoTraits });
|
const wrapper = mountDetail({ identity: identityWithNoTraits });
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
expect(wrapper.find('[data-testid="traits-table"]').exists()).toBe(false);
|
expect(wrapper.find('[data-testid="traits-table"]').exists()).toBe(false);
|
||||||
|
expect(wrapper.find('[data-testid="no-traits-message"]').exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ThenAddTraitButtonIsPresent', async () => {
|
||||||
|
const wrapper = mountDetail();
|
||||||
|
await flushPromises();
|
||||||
|
expect(wrapper.find('[data-testid="add-trait-btn"]').exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ThenAddTraitButtonIsDisabledWhenKeyIsEmpty', async () => {
|
||||||
|
const wrapper = mountDetail();
|
||||||
|
await flushPromises();
|
||||||
|
const btn = wrapper.find('[data-testid="add-trait-btn"]');
|
||||||
|
expect(btn.attributes('disabled')).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenAddTraitIsSubmitted', () => {
|
||||||
|
it('ThenUpsertIdentityTraitIsCalledWithKeyAndValue', async () => {
|
||||||
|
jest.mocked(identitiesApi.upsertIdentityTrait).mockResolvedValue({ key: 'role', value: 'admin' });
|
||||||
|
|
||||||
|
const wrapper = mountDetail();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
(wrapper.vm as any).newTraitKey = 'role';
|
||||||
|
(wrapper.vm as any).newTraitValue = 'admin';
|
||||||
|
await (wrapper.vm as any).onAddTrait();
|
||||||
|
|
||||||
|
expect(identitiesApi.upsertIdentityTrait).toHaveBeenCalledWith(
|
||||||
|
mockEnv.apiKey, mockIdentity.id, 'role', { value: 'admin' },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ThenNewTraitAppearsInTable', async () => {
|
||||||
|
jest.mocked(identitiesApi.upsertIdentityTrait).mockResolvedValue({ key: 'role', value: 'admin' });
|
||||||
|
|
||||||
|
const wrapper = mountDetail();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
(wrapper.vm as any).newTraitKey = 'role';
|
||||||
|
(wrapper.vm as any).newTraitValue = 'admin';
|
||||||
|
await (wrapper.vm as any).onAddTrait();
|
||||||
|
await wrapper.vm.$nextTick();
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-testid="trait-row-role"]').exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ThenInputsAreClearedAfterSuccess', async () => {
|
||||||
|
jest.mocked(identitiesApi.upsertIdentityTrait).mockResolvedValue({ key: 'role', value: 'admin' });
|
||||||
|
|
||||||
|
const wrapper = mountDetail();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
(wrapper.vm as any).newTraitKey = 'role';
|
||||||
|
(wrapper.vm as any).newTraitValue = 'admin';
|
||||||
|
await (wrapper.vm as any).onAddTrait();
|
||||||
|
|
||||||
|
expect((wrapper.vm as any).newTraitKey).toBe('');
|
||||||
|
expect((wrapper.vm as any).newTraitValue).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ThenErrorIsShownWhenApiFails', async () => {
|
||||||
|
jest.mocked(identitiesApi.upsertIdentityTrait).mockRejectedValue(new Error('fail'));
|
||||||
|
|
||||||
|
const wrapper = mountDetail();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
(wrapper.vm as any).newTraitKey = 'role';
|
||||||
|
await (wrapper.vm as any).onAddTrait();
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Failed to add trait');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenTraitValueIsSaved', () => {
|
||||||
|
it('ThenUpsertIdentityTraitIsCalledWithUpdatedValue', async () => {
|
||||||
|
jest.mocked(identitiesApi.upsertIdentityTrait).mockResolvedValue({ key: 'plan', value: 'enterprise' });
|
||||||
|
|
||||||
|
const wrapper = mountDetail();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
await (wrapper.vm as any).onSaveTrait('plan', 'enterprise');
|
||||||
|
|
||||||
|
expect(identitiesApi.upsertIdentityTrait).toHaveBeenCalledWith(
|
||||||
|
mockEnv.apiKey, mockIdentity.id, 'plan', { value: 'enterprise' },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WhenDeleteTraitIsClicked', () => {
|
||||||
|
it('ThenDeleteIdentityTraitIsCalledWithKey', async () => {
|
||||||
|
jest.mocked(identitiesApi.deleteIdentityTrait).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const wrapper = mountDetail();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
await wrapper.find('[data-testid="delete-trait-plan"]').trigger('click');
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(identitiesApi.deleteIdentityTrait).toHaveBeenCalledWith(
|
||||||
|
mockEnv.apiKey, mockIdentity.id, 'plan',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ThenTraitRowIsRemovedAfterDelete', async () => {
|
||||||
|
jest.mocked(identitiesApi.deleteIdentityTrait).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const wrapper = mountDetail();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-testid="trait-row-plan"]').exists()).toBe(true);
|
||||||
|
|
||||||
|
await wrapper.find('[data-testid="delete-trait-plan"]').trigger('click');
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-testid="trait-row-plan"]').exists()).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -218,6 +341,64 @@ describe('IdentityDetail', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('WhenSegmentsTabIsActive', () => {
|
||||||
|
it('ThenListIdentitySegmentsIsCalledOnOpen', async () => {
|
||||||
|
mountDetail();
|
||||||
|
await flushPromises();
|
||||||
|
expect(featureSegmentsApi.listIdentitySegments).toHaveBeenCalledWith(mockEnv.apiKey, mockIdentity.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ThenNoSegmentsMessageIsShownWhenIdentityMatchesNone', async () => {
|
||||||
|
const wrapper = mountDetail();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
await wrapper.find('[data-testid="tab-segments"]').trigger('click');
|
||||||
|
await wrapper.vm.$nextTick();
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-testid="no-segments-message"]').exists()).toBe(true);
|
||||||
|
expect(wrapper.find('[data-testid="segments-list"]').exists()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ThenSegmentChipsAreDisplayedWhenIdentityMatchesSegments', async () => {
|
||||||
|
jest.mocked(featureSegmentsApi.listIdentitySegments).mockResolvedValue(mockSegments);
|
||||||
|
|
||||||
|
const wrapper = mountDetail();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
await wrapper.find('[data-testid="tab-segments"]').trigger('click');
|
||||||
|
await wrapper.vm.$nextTick();
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-testid="segments-list"]').exists()).toBe(true);
|
||||||
|
expect(wrapper.find('[data-testid="segment-chip-1"]').exists()).toBe(true);
|
||||||
|
expect(wrapper.find('[data-testid="segment-chip-2"]').exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ThenSegmentNamesAreDisplayedInChips', async () => {
|
||||||
|
jest.mocked(featureSegmentsApi.listIdentitySegments).mockResolvedValue(mockSegments);
|
||||||
|
|
||||||
|
const wrapper = mountDetail();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
await wrapper.find('[data-testid="tab-segments"]').trigger('click');
|
||||||
|
await wrapper.vm.$nextTick();
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Pro Users');
|
||||||
|
expect(wrapper.text()).toContain('US Customers');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ThenSegmentsErrorIsShownWhenApiFails', async () => {
|
||||||
|
jest.mocked(featureSegmentsApi.listIdentitySegments).mockRejectedValue(new Error('Network error'));
|
||||||
|
|
||||||
|
const wrapper = mountDetail();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
await wrapper.find('[data-testid="tab-segments"]').trigger('click');
|
||||||
|
await wrapper.vm.$nextTick();
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Failed to load segments');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('WhenAddOverrideIsSubmitted', () => {
|
describe('WhenAddOverrideIsSubmitted', () => {
|
||||||
it('ThenSetIdentityFeatureStateIsCalledWithSelectedFeature', async () => {
|
it('ThenSetIdentityFeatureStateIsCalledWithSelectedFeature', async () => {
|
||||||
const newOverride: FeatureStateResponse = { ...mockOverride, featureId: 20, enabled: true };
|
const newOverride: FeatureStateResponse = { ...mockOverride, featureId: 20, enabled: true };
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ describe('SettingsView', () => {
|
|||||||
describe('WhenInviteMemberIsClicked', () => {
|
describe('WhenInviteMemberIsClicked', () => {
|
||||||
it('ThenInviteOrganizationMemberIsCalledWithUserId', async () => {
|
it('ThenInviteOrganizationMemberIsCalledWithUserId', async () => {
|
||||||
jest.mocked(orgsApi.inviteOrganizationMember).mockResolvedValue(undefined);
|
jest.mocked(orgsApi.inviteOrganizationMember).mockResolvedValue(undefined);
|
||||||
jest.mocked(orgsApi.listOrganizationMembers).mockResolvedValue([{ userId: 42, role: 'User' }]);
|
jest.mocked(orgsApi.listOrganizationMembers).mockResolvedValue([{ userId: 42, firstName: 'Jane', lastName: 'Doe', email: 'jane@example.com', role: 'User', lastLoginAt: null }]);
|
||||||
|
|
||||||
const contextStore = useContextStore();
|
const contextStore = useContextStore();
|
||||||
contextStore.setOrganization(mockOrg);
|
contextStore.setOrganization(mockOrg);
|
||||||
@@ -124,7 +124,7 @@ describe('SettingsView', () => {
|
|||||||
|
|
||||||
describe('WhenRemoveMemberIsClicked', () => {
|
describe('WhenRemoveMemberIsClicked', () => {
|
||||||
it('ThenRemoveOrganizationMemberIsCalledAndMemberIsRemovedFromList', async () => {
|
it('ThenRemoveOrganizationMemberIsCalledAndMemberIsRemovedFromList', async () => {
|
||||||
jest.mocked(orgsApi.listOrganizationMembers).mockResolvedValue([{ userId: 5, role: 'User' }]);
|
jest.mocked(orgsApi.listOrganizationMembers).mockResolvedValue([{ userId: 5, firstName: 'John', lastName: 'Smith', email: 'john@example.com', role: 'User', lastLoginAt: null }]);
|
||||||
jest.mocked(orgsApi.removeOrganizationMember).mockResolvedValue(undefined);
|
jest.mocked(orgsApi.removeOrganizationMember).mockResolvedValue(undefined);
|
||||||
|
|
||||||
const contextStore = useContextStore();
|
const contextStore = useContextStore();
|
||||||
|
|||||||
30
src/admin/vite.config.ts
Normal file
30
src/admin/vite.config.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import vuetify from 'vite-plugin-vuetify';
|
||||||
|
import { fileURLToPath, URL } from 'node:url';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
vuetify({ autoImport: true }),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
entryFileNames: 'js/[name].[hash].js',
|
||||||
|
chunkFileNames: 'js/[name].[hash].js',
|
||||||
|
assetFileNames: ({ name }) => {
|
||||||
|
if (name?.match(/\.(woff2?|eot|ttf|otf)$/)) return 'fonts/[name].[hash][extname]';
|
||||||
|
if (name?.match(/\.(png|jpe?g|gif|webp|svg)$/)) return 'img/[name].[hash][extname]';
|
||||||
|
return 'assets/[name].[hash][extname]';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
const path = require('path');
|
|
||||||
const { VueLoaderPlugin } = require('vue-loader');
|
|
||||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
|
||||||
const { VuetifyPlugin } = require('webpack-plugin-vuetify');
|
|
||||||
|
|
||||||
module.exports = (env, argv) => {
|
|
||||||
const isProd = argv.mode === 'production';
|
|
||||||
|
|
||||||
return {
|
|
||||||
entry: './src/main.ts',
|
|
||||||
|
|
||||||
output: {
|
|
||||||
filename: isProd ? 'js/[name].[contenthash:8].js' : 'js/[name].js',
|
|
||||||
path: path.resolve(__dirname, 'dist'),
|
|
||||||
publicPath: '/',
|
|
||||||
clean: true,
|
|
||||||
},
|
|
||||||
|
|
||||||
resolve: {
|
|
||||||
extensions: ['.ts', '.tsx', '.js', '.vue', '.json'],
|
|
||||||
alias: {
|
|
||||||
'@': path.resolve(__dirname, 'src'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
module: {
|
|
||||||
rules: [
|
|
||||||
{
|
|
||||||
test: /\.vue$/,
|
|
||||||
loader: 'vue-loader',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
test: /\.tsx?$/,
|
|
||||||
loader: 'ts-loader',
|
|
||||||
exclude: /node_modules/,
|
|
||||||
options: {
|
|
||||||
appendTsSuffixTo: [/\.vue$/],
|
|
||||||
transpileOnly: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
test: /\.s[ac]ss$/,
|
|
||||||
use: ['style-loader', 'css-loader', 'sass-loader'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
test: /\.css$/,
|
|
||||||
use: ['style-loader', 'css-loader'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
test: /\.svg$/,
|
|
||||||
type: 'asset/resource',
|
|
||||||
generator: {
|
|
||||||
filename: 'img/[name].[hash:8][ext]',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
test: /\.(png|jpe?g|gif|webp)$/,
|
|
||||||
type: 'asset/resource',
|
|
||||||
generator: {
|
|
||||||
filename: 'img/[name].[hash:8][ext]',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
test: /\.(woff2?|eot|ttf|otf)$/,
|
|
||||||
type: 'asset/resource',
|
|
||||||
generator: {
|
|
||||||
filename: 'fonts/[name].[hash:8][ext]',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
|
|
||||||
plugins: [
|
|
||||||
new VueLoaderPlugin(),
|
|
||||||
new VuetifyPlugin({ autoImport: true }),
|
|
||||||
new HtmlWebpackPlugin({
|
|
||||||
template: './public/index.html',
|
|
||||||
filename: 'index.html',
|
|
||||||
inject: 'body',
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
|
|
||||||
devServer: {
|
|
||||||
port: 8080,
|
|
||||||
hot: true,
|
|
||||||
historyApiFallback: true,
|
|
||||||
static: {
|
|
||||||
directory: path.resolve(__dirname, 'public'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
devtool: isProd ? 'source-map' : 'eval-cheap-module-source-map',
|
|
||||||
|
|
||||||
optimization: {
|
|
||||||
splitChunks: {
|
|
||||||
chunks: 'all',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -18,7 +18,7 @@ public class UserConfiguration : IEntityTypeConfiguration<User>
|
|||||||
builder.HasIndex(u => u.Email).IsUnique();
|
builder.HasIndex(u => u.Email).IsUnique();
|
||||||
|
|
||||||
builder.HasMany(u => u.Organizations)
|
builder.HasMany(u => u.Organizations)
|
||||||
.WithOne()
|
.WithOne(ou => ou.User)
|
||||||
.HasForeignKey(ou => ou.UserId)
|
.HasForeignKey(ou => ou.UserId)
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,4 @@
|
|||||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
|
||||||
WORKDIR /src
|
|
||||||
COPY ["src/api/MicCheck.Api/MicCheck.Api.csproj", "src/api/MicCheck.Api/"]
|
|
||||||
RUN dotnet restore "src/api/MicCheck.Api/MicCheck.Api.csproj"
|
|
||||||
COPY . .
|
|
||||||
RUN dotnet build "src/api/MicCheck.Api/MicCheck.Api.csproj" -c Release -o /app/build
|
|
||||||
|
|
||||||
FROM build AS publish
|
|
||||||
RUN dotnet publish "src/api/MicCheck.Api/MicCheck.Api.csproj" -c Release -o /app/publish /p:UseAppHost=false
|
|
||||||
|
|
||||||
FROM base AS final
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=publish /app/publish .
|
|
||||||
ENTRYPOINT ["dotnet", "MicCheck.Api.dll"]
|
ENTRYPOINT ["dotnet", "MicCheck.Api.dll"]
|
||||||
|
|||||||
@@ -163,6 +163,24 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
|
|||||||
return Ok(new PaginatedResponse<Identities.AdminIdentityResponse>(total, null, null, results));
|
return Ok(new PaginatedResponse<Identities.AdminIdentityResponse>(total, null, null, results));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("{apiKey}/identities")]
|
||||||
|
public async Task<ActionResult<Identities.AdminIdentityResponse>> CreateIdentity(
|
||||||
|
string apiKey,
|
||||||
|
[FromBody] Identities.CreateIdentityRequest request,
|
||||||
|
[FromServices] Identities.AdminIdentityService adminIdentityService,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||||
|
if (environment is null) return NotFound();
|
||||||
|
|
||||||
|
var identity = await adminIdentityService.CreateAsync(environment.Id, request.Identifier, ct);
|
||||||
|
if (identity is null)
|
||||||
|
return Conflict(new { error = "An identity with this identifier already exists in the environment." });
|
||||||
|
|
||||||
|
return CreatedAtAction(nameof(GetIdentity), new { apiKey, id = identity.Id },
|
||||||
|
Identities.AdminIdentityResponse.From(identity));
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("{apiKey}/identities/{id}")]
|
[HttpGet("{apiKey}/identities/{id}")]
|
||||||
public async Task<ActionResult<Identities.AdminIdentityResponse>> GetIdentity(
|
public async Task<ActionResult<Identities.AdminIdentityResponse>> GetIdentity(
|
||||||
string apiKey, int id,
|
string apiKey, int id,
|
||||||
@@ -193,6 +211,35 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
|
|||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPut("{apiKey}/identities/{id}/traits/{key}")]
|
||||||
|
public async Task<ActionResult<Identities.TraitResponse>> UpsertIdentityTrait(
|
||||||
|
string apiKey, int id, string key,
|
||||||
|
[FromBody] Identities.UpsertTraitRequest request,
|
||||||
|
[FromServices] Identities.AdminIdentityService adminIdentityService,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||||
|
if (environment is null) return NotFound();
|
||||||
|
|
||||||
|
var result = await adminIdentityService.UpsertTraitAsync(id, environment.Id, key, request.Value, ct);
|
||||||
|
if (result is null) return NotFound();
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{apiKey}/identities/{id}/traits/{key}")]
|
||||||
|
public async Task<IActionResult> DeleteIdentityTrait(
|
||||||
|
string apiKey, int id, string key,
|
||||||
|
[FromServices] Identities.AdminIdentityService adminIdentityService,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||||
|
if (environment is null) return NotFound();
|
||||||
|
|
||||||
|
var deleted = await adminIdentityService.DeleteTraitAsync(id, environment.Id, key, ct);
|
||||||
|
if (!deleted) return NotFound();
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("{apiKey}/identities/{id}/featurestates")]
|
[HttpGet("{apiKey}/identities/{id}/featurestates")]
|
||||||
public async Task<ActionResult<IReadOnlyList<Features.FeatureStateResponse>>> GetIdentityFeatureStates(
|
public async Task<ActionResult<IReadOnlyList<Features.FeatureStateResponse>>> GetIdentityFeatureStates(
|
||||||
string apiKey, int id,
|
string apiKey, int id,
|
||||||
@@ -302,4 +349,95 @@ public class EnvironmentsController(EnvironmentService environmentService, Webho
|
|||||||
var updated = await featureStateService.PatchAsync(id, request.Enabled, request.Value, ct);
|
var updated = await featureStateService.PatchAsync(id, request.Enabled, request.Value, ct);
|
||||||
return Ok(Features.FeatureStateResponse.From(updated));
|
return Ok(Features.FeatureStateResponse.From(updated));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Feature Segments ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[HttpGet("{apiKey}/features/{featureId}/segments")]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<Features.FeatureSegmentResponse>>> ListFeatureSegments(
|
||||||
|
string apiKey, int featureId,
|
||||||
|
[FromServices] Features.FeatureSegmentService featureSegmentService,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||||
|
if (environment is null) return NotFound();
|
||||||
|
|
||||||
|
var results = await featureSegmentService.ListByFeatureAsync(featureId, environment.Id, ct);
|
||||||
|
return Ok(results);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{apiKey}/features/{featureId}/segments")]
|
||||||
|
public async Task<ActionResult<Features.FeatureSegmentResponse>> CreateFeatureSegment(
|
||||||
|
string apiKey, int featureId,
|
||||||
|
Features.CreateFeatureSegmentRequest request,
|
||||||
|
[FromServices] Features.FeatureSegmentService featureSegmentService,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||||
|
if (environment is null) return NotFound();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await featureSegmentService.CreateAsync(
|
||||||
|
featureId, environment.Id, request.SegmentId, request.Priority,
|
||||||
|
request.Enabled, request.Value, ct);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException ex)
|
||||||
|
{
|
||||||
|
return NotFound(new { error = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{apiKey}/features/{featureId}/segments/{id}")]
|
||||||
|
public async Task<ActionResult<Features.FeatureSegmentResponse>> UpdateFeatureSegment(
|
||||||
|
string apiKey, int featureId, int id,
|
||||||
|
Features.UpdateFeatureSegmentRequest request,
|
||||||
|
[FromServices] Features.FeatureSegmentService featureSegmentService,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||||
|
if (environment is null) return NotFound();
|
||||||
|
|
||||||
|
var result = await featureSegmentService.UpdateAsync(id, request.Priority, request.Enabled, request.Value, ct);
|
||||||
|
if (result is null) return NotFound();
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{apiKey}/features/{featureId}/segments/{id}")]
|
||||||
|
public async Task<IActionResult> DeleteFeatureSegment(
|
||||||
|
string apiKey, int featureId, int id,
|
||||||
|
[FromServices] Features.FeatureSegmentService featureSegmentService,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||||
|
if (environment is null) return NotFound();
|
||||||
|
|
||||||
|
var deleted = await featureSegmentService.DeleteAsync(id, ct);
|
||||||
|
return deleted ? NoContent() : NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Identity Segments ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[HttpGet("{apiKey}/identities/{id}/segments")]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<Segments.SegmentSummaryResponse>>> GetIdentitySegments(
|
||||||
|
string apiKey, int id,
|
||||||
|
[FromServices] Identities.AdminIdentityService adminIdentityService,
|
||||||
|
[FromServices] Segments.SegmentService segmentService,
|
||||||
|
[FromServices] Segments.SegmentEvaluator segmentEvaluator,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||||
|
if (environment is null) return NotFound();
|
||||||
|
|
||||||
|
var identity = await adminIdentityService.FindByIdAsync(id, environment.Id, ct);
|
||||||
|
if (identity is null) return NotFound();
|
||||||
|
|
||||||
|
var segments = await segmentService.ListByProjectAsync(environment.ProjectId, ct);
|
||||||
|
var matching = segments
|
||||||
|
.Where(s => segmentEvaluator.Evaluate(s, identity.Traits.ToList(), identity.Identifier))
|
||||||
|
.Select(s => new Segments.SegmentSummaryResponse(s.Id, s.Name))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return Ok(matching);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
5
src/api/MicCheck.Api/Features/FeatureSegmentRequest.cs
Normal file
5
src/api/MicCheck.Api/Features/FeatureSegmentRequest.cs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
namespace MicCheck.Api.Features;
|
||||||
|
|
||||||
|
public record CreateFeatureSegmentRequest(int SegmentId, int Priority, bool Enabled, string? Value);
|
||||||
|
|
||||||
|
public record UpdateFeatureSegmentRequest(int Priority, bool Enabled, string? Value);
|
||||||
12
src/api/MicCheck.Api/Features/FeatureSegmentResponse.cs
Normal file
12
src/api/MicCheck.Api/Features/FeatureSegmentResponse.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
namespace MicCheck.Api.Features;
|
||||||
|
|
||||||
|
public record FeatureSegmentResponse(
|
||||||
|
int Id,
|
||||||
|
int FeatureId,
|
||||||
|
int SegmentId,
|
||||||
|
string SegmentName,
|
||||||
|
int EnvironmentId,
|
||||||
|
int Priority,
|
||||||
|
bool? Enabled,
|
||||||
|
string? Value
|
||||||
|
);
|
||||||
124
src/api/MicCheck.Api/Features/FeatureSegmentService.cs
Normal file
124
src/api/MicCheck.Api/Features/FeatureSegmentService.cs
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
using MicCheck.Api.Data;
|
||||||
|
using MicCheck.Api.Segments;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace MicCheck.Api.Features;
|
||||||
|
|
||||||
|
public class FeatureSegmentService(MicCheckDbContext db)
|
||||||
|
{
|
||||||
|
public async Task<IReadOnlyList<FeatureSegmentResponse>> ListByFeatureAsync(
|
||||||
|
int featureId, int environmentId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var featureSegments = await db.FeatureSegments
|
||||||
|
.Where(fsg => fsg.FeatureId == featureId && fsg.EnvironmentId == environmentId)
|
||||||
|
.OrderBy(fsg => fsg.Priority)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var segmentIds = featureSegments.Select(fsg => fsg.SegmentId).Distinct().ToList();
|
||||||
|
var segments = await db.Segments
|
||||||
|
.Where(s => segmentIds.Contains(s.Id))
|
||||||
|
.ToDictionaryAsync(s => s.Id, ct);
|
||||||
|
|
||||||
|
var states = await db.FeatureStates
|
||||||
|
.Where(fs => fs.FeatureId == featureId && fs.EnvironmentId == environmentId && fs.FeatureSegmentId != null)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
var stateBySegment = states.ToDictionary(fs => fs.FeatureSegmentId!.Value);
|
||||||
|
|
||||||
|
return featureSegments.Select(fsg =>
|
||||||
|
{
|
||||||
|
var segmentName = segments.TryGetValue(fsg.SegmentId, out var seg) ? seg.Name : "Unknown";
|
||||||
|
stateBySegment.TryGetValue(fsg.Id, out var state);
|
||||||
|
return new FeatureSegmentResponse(
|
||||||
|
fsg.Id, fsg.FeatureId, fsg.SegmentId, segmentName,
|
||||||
|
fsg.EnvironmentId, fsg.Priority,
|
||||||
|
state?.Enabled, state?.Value);
|
||||||
|
}).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<FeatureSegmentResponse> CreateAsync(
|
||||||
|
int featureId, int environmentId, int segmentId, int priority, bool enabled, string? value,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var segment = await db.Segments.FirstOrDefaultAsync(s => s.Id == segmentId, ct)
|
||||||
|
?? throw new KeyNotFoundException($"Segment {segmentId} not found.");
|
||||||
|
|
||||||
|
var featureSegment = new FeatureSegment
|
||||||
|
{
|
||||||
|
FeatureId = featureId,
|
||||||
|
SegmentId = segmentId,
|
||||||
|
EnvironmentId = environmentId,
|
||||||
|
Priority = priority
|
||||||
|
};
|
||||||
|
db.FeatureSegments.Add(featureSegment);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
var state = new FeatureState
|
||||||
|
{
|
||||||
|
FeatureId = featureId,
|
||||||
|
EnvironmentId = environmentId,
|
||||||
|
FeatureSegmentId = featureSegment.Id,
|
||||||
|
Enabled = enabled,
|
||||||
|
Value = value,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
UpdatedAt = DateTimeOffset.UtcNow
|
||||||
|
};
|
||||||
|
db.FeatureStates.Add(state);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return new FeatureSegmentResponse(
|
||||||
|
featureSegment.Id, featureId, segmentId, segment.Name,
|
||||||
|
environmentId, priority, enabled, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<FeatureSegmentResponse?> UpdateAsync(
|
||||||
|
int id, int priority, bool enabled, string? value, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var featureSegment = await db.FeatureSegments.FirstOrDefaultAsync(fsg => fsg.Id == id, ct);
|
||||||
|
if (featureSegment is null) return null;
|
||||||
|
|
||||||
|
var segment = await db.Segments.FirstOrDefaultAsync(s => s.Id == featureSegment.SegmentId, ct);
|
||||||
|
|
||||||
|
featureSegment.Priority = priority;
|
||||||
|
|
||||||
|
var state = await db.FeatureStates
|
||||||
|
.FirstOrDefaultAsync(fs => fs.FeatureSegmentId == id, ct);
|
||||||
|
|
||||||
|
if (state is not null)
|
||||||
|
{
|
||||||
|
state.Enabled = enabled;
|
||||||
|
state.Value = value;
|
||||||
|
state.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
state = new FeatureState
|
||||||
|
{
|
||||||
|
FeatureId = featureSegment.FeatureId,
|
||||||
|
EnvironmentId = featureSegment.EnvironmentId,
|
||||||
|
FeatureSegmentId = featureSegment.Id,
|
||||||
|
Enabled = enabled,
|
||||||
|
Value = value,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
UpdatedAt = DateTimeOffset.UtcNow
|
||||||
|
};
|
||||||
|
db.FeatureStates.Add(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return new FeatureSegmentResponse(
|
||||||
|
featureSegment.Id, featureSegment.FeatureId, featureSegment.SegmentId,
|
||||||
|
segment?.Name ?? "Unknown", featureSegment.EnvironmentId,
|
||||||
|
priority, enabled, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> DeleteAsync(int id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var featureSegment = await db.FeatureSegments.FirstOrDefaultAsync(fsg => fsg.Id == id, ct);
|
||||||
|
if (featureSegment is null) return false;
|
||||||
|
|
||||||
|
db.FeatureSegments.Remove(featureSegment);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,25 @@ public class AdminIdentityService(MicCheckDbContext db)
|
|||||||
return (total, items);
|
return (total, items);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<Identity?> CreateAsync(int environmentId, string identifier, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var existing = await db.Identities
|
||||||
|
.FirstOrDefaultAsync(i => i.EnvironmentId == environmentId && i.Identifier == identifier, ct);
|
||||||
|
|
||||||
|
if (existing is not null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var identity = new Identity
|
||||||
|
{
|
||||||
|
Identifier = identifier,
|
||||||
|
EnvironmentId = environmentId,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
};
|
||||||
|
db.Identities.Add(identity);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return identity;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<Identity?> FindByIdAsync(int id, int environmentId, CancellationToken ct = default)
|
public async Task<Identity?> FindByIdAsync(int id, int environmentId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
return await db.Identities
|
return await db.Identities
|
||||||
@@ -30,6 +49,53 @@ public class AdminIdentityService(MicCheckDbContext db)
|
|||||||
.FirstOrDefaultAsync(i => i.Id == id && i.EnvironmentId == environmentId, ct);
|
.FirstOrDefaultAsync(i => i.Id == id && i.EnvironmentId == environmentId, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<TraitResponse?> UpsertTraitAsync(
|
||||||
|
int identityId, int environmentId, string key, string value, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var identity = await db.Identities
|
||||||
|
.Include(i => i.Traits)
|
||||||
|
.FirstOrDefaultAsync(i => i.Id == identityId && i.EnvironmentId == environmentId, ct);
|
||||||
|
|
||||||
|
if (identity is null) return null;
|
||||||
|
|
||||||
|
var existing = identity.Traits.FirstOrDefault(t => t.Key == key);
|
||||||
|
if (existing is not null)
|
||||||
|
{
|
||||||
|
existing.Value = value;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var trait = new IdentityTrait
|
||||||
|
{
|
||||||
|
IdentityId = identityId,
|
||||||
|
Key = key,
|
||||||
|
Value = value,
|
||||||
|
ValueType = TraitValueType.String,
|
||||||
|
};
|
||||||
|
db.IdentityTraits.Add(trait);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return new TraitResponse(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> DeleteTraitAsync(
|
||||||
|
int identityId, int environmentId, string key, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var identity = await db.Identities
|
||||||
|
.Include(i => i.Traits)
|
||||||
|
.FirstOrDefaultAsync(i => i.Id == identityId && i.EnvironmentId == environmentId, ct);
|
||||||
|
|
||||||
|
if (identity is null) return false;
|
||||||
|
|
||||||
|
var trait = identity.Traits.FirstOrDefault(t => t.Key == key);
|
||||||
|
if (trait is null) return false;
|
||||||
|
|
||||||
|
db.IdentityTraits.Remove(trait);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task DeleteAsync(int id, CancellationToken ct = default)
|
public async Task DeleteAsync(int id, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var identity = await db.Identities.FirstOrDefaultAsync(i => i.Id == id, ct);
|
var identity = await db.Identities.FirstOrDefaultAsync(i => i.Id == id, ct);
|
||||||
|
|||||||
3
src/api/MicCheck.Api/Identities/CreateIdentityRequest.cs
Normal file
3
src/api/MicCheck.Api/Identities/CreateIdentityRequest.cs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
namespace MicCheck.Api.Identities;
|
||||||
|
|
||||||
|
public record CreateIdentityRequest(string Identifier);
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
namespace MicCheck.Api.Identities;
|
namespace MicCheck.Api.Identities;
|
||||||
|
|
||||||
public record TraitResponse(string TraitKey, string TraitValue)
|
public record TraitResponse(string Key, string Value)
|
||||||
{
|
{
|
||||||
public static TraitResponse From(IdentityTrait trait) =>
|
public static TraitResponse From(IdentityTrait trait) =>
|
||||||
new(trait.Key, trait.Value);
|
new(trait.Key, trait.Value);
|
||||||
|
|||||||
3
src/api/MicCheck.Api/Identities/UpsertTraitRequest.cs
Normal file
3
src/api/MicCheck.Api/Identities/UpsertTraitRequest.cs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
namespace MicCheck.Api.Identities;
|
||||||
|
|
||||||
|
public record UpsertTraitRequest(string Key, string Value);
|
||||||
928
src/api/MicCheck.Api/Migrations/20260416184635_AddOrganizationUserNavigation.Designer.cs
generated
Normal file
928
src/api/MicCheck.Api/Migrations/20260416184635_AddOrganizationUserNavigation.Designer.cs
generated
Normal file
@@ -0,0 +1,928 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using MicCheck.Api.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace MicCheck.Api.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(MicCheckDbContext))]
|
||||||
|
[Migration("20260416184635_AddOrganizationUserNavigation")]
|
||||||
|
partial class AddOrganizationUserNavigation
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.5")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("FeatureTags", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("FeatureId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("TagsId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("FeatureId", "TagsId");
|
||||||
|
|
||||||
|
b.HasIndex("TagsId");
|
||||||
|
|
||||||
|
b.ToTable("FeatureTags");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MicCheck.Api.ApiKeys.ApiKey", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("ExpiresAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("Key")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<int>("OrganizationId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Prefix")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(8)
|
||||||
|
.HasColumnType("character varying(8)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Key")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("OrganizationId");
|
||||||
|
|
||||||
|
b.ToTable("ApiKeys");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MicCheck.Api.Audit.AuditLog", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("Action")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<int?>("ActorUserId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Changes")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int?>("EnvironmentId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("OrganizationId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int?>("ProjectId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("ResourceId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("ResourceType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("OrganizationId", "CreatedAt");
|
||||||
|
|
||||||
|
b.ToTable("AuditLogs");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MicCheck.Api.Authorization.UserProjectPermission", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("UserId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("ProjectId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<bool>("IsAdmin")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("Permissions")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("UserId", "ProjectId");
|
||||||
|
|
||||||
|
b.ToTable("UserProjectPermissions");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MicCheck.Api.Environments.Environment", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ApiKey")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<int>("ProjectId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ApiKey")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("ProjectId");
|
||||||
|
|
||||||
|
b.ToTable("Environments");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MicCheck.Api.Features.Feature", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<bool>("DefaultEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("character varying(2000)");
|
||||||
|
|
||||||
|
b.Property<string>("InitialValue")
|
||||||
|
.HasMaxLength(20000)
|
||||||
|
.HasColumnType("character varying(20000)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(150)
|
||||||
|
.HasColumnType("character varying(150)");
|
||||||
|
|
||||||
|
b.Property<int>("ProjectId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ProjectId", "Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Features");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MicCheck.Api.Features.FeatureSegment", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("EnvironmentId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("FeatureId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("Priority")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("SegmentId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("FeatureSegments");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MicCheck.Api.Features.FeatureState", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<bool>("Enabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<int>("EnvironmentId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("FeatureId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int?>("FeatureSegmentId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int?>("IdentityId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Value")
|
||||||
|
.HasMaxLength(20000)
|
||||||
|
.HasColumnType("character varying(20000)");
|
||||||
|
|
||||||
|
b.Property<int>("Version")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("EnvironmentId");
|
||||||
|
|
||||||
|
b.HasIndex("FeatureSegmentId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("IdentityId");
|
||||||
|
|
||||||
|
b.HasIndex("FeatureId", "EnvironmentId", "IdentityId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("FeatureStates");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MicCheck.Api.Features.Tag", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("Color")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<string>("Label")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<int>("ProjectId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ProjectId");
|
||||||
|
|
||||||
|
b.ToTable("Tags");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MicCheck.Api.Identities.Identity", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("EnvironmentId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Identifier")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("character varying(2000)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("EnvironmentId", "Identifier")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Identities");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MicCheck.Api.Identities.IdentityTrait", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("IdentityId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Key")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("Value")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("character varying(2000)");
|
||||||
|
|
||||||
|
b.Property<int>("ValueType")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("IdentityId", "Key")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("IdentityTraits");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MicCheck.Api.Organizations.Organization", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Organizations");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MicCheck.Api.Organizations.OrganizationUser", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("OrganizationId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("UserId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("Role")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("OrganizationId", "UserId");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("OrganizationUsers");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MicCheck.Api.Projects.Project", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<bool>("HideDisabledFlags")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<int>("OrganizationId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("OrganizationId");
|
||||||
|
|
||||||
|
b.ToTable("Projects");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MicCheck.Api.Segments.Segment", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<int>("ProjectId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ProjectId");
|
||||||
|
|
||||||
|
b.ToTable("Segments");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MicCheck.Api.Segments.SegmentCondition", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("Operator")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Property")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<int>("RuleId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Value")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("character varying(1000)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("RuleId");
|
||||||
|
|
||||||
|
b.ToTable("SegmentConditions");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MicCheck.Api.Segments.SegmentRule", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int?>("ParentRuleId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("SegmentId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ParentRuleId");
|
||||||
|
|
||||||
|
b.HasIndex("SegmentId");
|
||||||
|
|
||||||
|
b.ToTable("SegmentRules");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MicCheck.Api.Users.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("ExpiresAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<bool>("IsRevoked")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("Token")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)");
|
||||||
|
|
||||||
|
b.Property<int>("UserId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Token")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("RefreshTokens");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MicCheck.Api.Users.User", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<string>("FirstName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("IsTwoFactorEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LastLoginAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("LastName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Email")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MicCheck.Api.Webhooks.Webhook", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<bool>("Enabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<int?>("EnvironmentId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int?>("OrganizationId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("Scope")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Secret")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("Url")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("EnvironmentId");
|
||||||
|
|
||||||
|
b.ToTable("Webhooks");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MicCheck.Api.Webhooks.WebhookDeliveryLog", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("AttemptNumber")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("AttemptedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<TimeSpan>("Duration")
|
||||||
|
.HasColumnType("interval");
|
||||||
|
|
||||||
|
b.Property<string>("ErrorMessage")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("EventType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("PayloadJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ResponseBody")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<int?>("ResponseStatusCode")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<bool>("Success")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<int>("WebhookId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("WebhookId");
|
||||||
|
|
||||||
|
b.ToTable("WebhookDeliveryLogs");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FeatureTags", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("MicCheck.Api.Features.Feature", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FeatureId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("MicCheck.Api.Features.Tag", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TagsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MicCheck.Api.ApiKeys.ApiKey", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("MicCheck.Api.Organizations.Organization", null)
|
||||||
|
.WithMany("ApiKeys")
|
||||||
|
.HasForeignKey("OrganizationId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MicCheck.Api.Environments.Environment", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("MicCheck.Api.Projects.Project", null)
|
||||||
|
.WithMany("Environments")
|
||||||
|
.HasForeignKey("ProjectId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MicCheck.Api.Features.Feature", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("MicCheck.Api.Projects.Project", null)
|
||||||
|
.WithMany("Features")
|
||||||
|
.HasForeignKey("ProjectId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MicCheck.Api.Features.FeatureState", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("MicCheck.Api.Environments.Environment", null)
|
||||||
|
.WithMany("FeatureStates")
|
||||||
|
.HasForeignKey("EnvironmentId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("MicCheck.Api.Features.Feature", null)
|
||||||
|
.WithMany("FeatureStates")
|
||||||
|
.HasForeignKey("FeatureId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("MicCheck.Api.Features.FeatureSegment", null)
|
||||||
|
.WithOne("FeatureState")
|
||||||
|
.HasForeignKey("MicCheck.Api.Features.FeatureState", "FeatureSegmentId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.HasOne("MicCheck.Api.Identities.Identity", null)
|
||||||
|
.WithMany("FeatureStateOverrides")
|
||||||
|
.HasForeignKey("IdentityId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MicCheck.Api.Features.Tag", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("MicCheck.Api.Projects.Project", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ProjectId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MicCheck.Api.Identities.IdentityTrait", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("MicCheck.Api.Identities.Identity", null)
|
||||||
|
.WithMany("Traits")
|
||||||
|
.HasForeignKey("IdentityId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MicCheck.Api.Organizations.OrganizationUser", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("MicCheck.Api.Organizations.Organization", null)
|
||||||
|
.WithMany("Members")
|
||||||
|
.HasForeignKey("OrganizationId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("MicCheck.Api.Users.User", "User")
|
||||||
|
.WithMany("Organizations")
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MicCheck.Api.Projects.Project", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("MicCheck.Api.Organizations.Organization", null)
|
||||||
|
.WithMany("Projects")
|
||||||
|
.HasForeignKey("OrganizationId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MicCheck.Api.Segments.Segment", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("MicCheck.Api.Projects.Project", null)
|
||||||
|
.WithMany("Segments")
|
||||||
|
.HasForeignKey("ProjectId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MicCheck.Api.Segments.SegmentCondition", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("MicCheck.Api.Segments.SegmentRule", null)
|
||||||
|
.WithMany("Conditions")
|
||||||
|
.HasForeignKey("RuleId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MicCheck.Api.Segments.SegmentRule", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("MicCheck.Api.Segments.SegmentRule", null)
|
||||||
|
.WithMany("ChildRules")
|
||||||
|
.HasForeignKey("ParentRuleId")
|
||||||
|
.OnDelete(DeleteBehavior.NoAction);
|
||||||
|
|
||||||
|
b.HasOne("MicCheck.Api.Segments.Segment", null)
|
||||||
|
.WithMany("Rules")
|
||||||
|
.HasForeignKey("SegmentId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MicCheck.Api.Users.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("MicCheck.Api.Users.User", "User")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MicCheck.Api.Environments.Environment", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("FeatureStates");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MicCheck.Api.Features.Feature", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("FeatureStates");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MicCheck.Api.Features.FeatureSegment", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("FeatureState");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MicCheck.Api.Identities.Identity", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("FeatureStateOverrides");
|
||||||
|
|
||||||
|
b.Navigation("Traits");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MicCheck.Api.Organizations.Organization", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("ApiKeys");
|
||||||
|
|
||||||
|
b.Navigation("Members");
|
||||||
|
|
||||||
|
b.Navigation("Projects");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MicCheck.Api.Projects.Project", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Environments");
|
||||||
|
|
||||||
|
b.Navigation("Features");
|
||||||
|
|
||||||
|
b.Navigation("Segments");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MicCheck.Api.Segments.Segment", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Rules");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MicCheck.Api.Segments.SegmentRule", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("ChildRules");
|
||||||
|
|
||||||
|
b.Navigation("Conditions");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MicCheck.Api.Users.User", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Organizations");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace MicCheck.Api.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddOrganizationUserNavigation : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -802,11 +802,13 @@ namespace MicCheck.Api.Migrations
|
|||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
b.HasOne("MicCheck.Api.Users.User", null)
|
b.HasOne("MicCheck.Api.Users.User", "User")
|
||||||
.WithMany("Organizations")
|
.WithMany("Organizations")
|
||||||
.HasForeignKey("UserId")
|
.HasForeignKey("UserId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("MicCheck.Api.Projects.Project", b =>
|
modelBuilder.Entity("MicCheck.Api.Projects.Project", b =>
|
||||||
|
|||||||
@@ -12,9 +12,18 @@ public record OrganizationResponse(
|
|||||||
|
|
||||||
public record OrganizationMemberResponse(
|
public record OrganizationMemberResponse(
|
||||||
int UserId,
|
int UserId,
|
||||||
string Role
|
string FirstName,
|
||||||
|
string LastName,
|
||||||
|
string Email,
|
||||||
|
string Role,
|
||||||
|
DateTimeOffset? LastLoginAt
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
public static OrganizationMemberResponse From(OrganizationUser member) => new(
|
public static OrganizationMemberResponse From(OrganizationUser member) => new(
|
||||||
member.UserId, member.Role.ToString());
|
member.UserId,
|
||||||
|
member.User.FirstName,
|
||||||
|
member.User.LastName,
|
||||||
|
member.User.Email,
|
||||||
|
member.Role.ToString(),
|
||||||
|
member.User.LastLoginAt);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ public class OrganizationService(MicCheckDbContext db, AuditService auditService
|
|||||||
{
|
{
|
||||||
return await db.OrganizationUsers
|
return await db.OrganizationUsers
|
||||||
.Where(m => m.OrganizationId == organizationId)
|
.Where(m => m.OrganizationId == organizationId)
|
||||||
|
.Include(m => m.User)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using MicCheck.Api.Users;
|
||||||
|
|
||||||
namespace MicCheck.Api.Organizations;
|
namespace MicCheck.Api.Organizations;
|
||||||
|
|
||||||
public class OrganizationUser
|
public class OrganizationUser
|
||||||
@@ -5,6 +7,7 @@ public class OrganizationUser
|
|||||||
public int OrganizationId { get; init; }
|
public int OrganizationId { get; init; }
|
||||||
public int UserId { get; init; }
|
public int UserId { get; init; }
|
||||||
public OrganizationRole Role { get; set; }
|
public OrganizationRole Role { get; set; }
|
||||||
|
public User User { get; init; } = null!;
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum OrganizationRole { User, Admin }
|
public enum OrganizationRole { User, Admin }
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ try
|
|||||||
builder.Services.AddScoped<EnvironmentService>();
|
builder.Services.AddScoped<EnvironmentService>();
|
||||||
builder.Services.AddScoped<FeatureService>();
|
builder.Services.AddScoped<FeatureService>();
|
||||||
builder.Services.AddScoped<FeatureStateService>();
|
builder.Services.AddScoped<FeatureStateService>();
|
||||||
|
builder.Services.AddScoped<FeatureSegmentService>();
|
||||||
builder.Services.AddScoped<SegmentService>();
|
builder.Services.AddScoped<SegmentService>();
|
||||||
builder.Services.AddScoped<TagService>();
|
builder.Services.AddScoped<TagService>();
|
||||||
builder.Services.AddScoped<WebhookService>();
|
builder.Services.AddScoped<WebhookService>();
|
||||||
|
|||||||
3
src/api/MicCheck.Api/Segments/SegmentSummaryResponse.cs
Normal file
3
src/api/MicCheck.Api/Segments/SegmentSummaryResponse.cs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
namespace MicCheck.Api.Segments;
|
||||||
|
|
||||||
|
public record SegmentSummaryResponse(int Id, string Name);
|
||||||
Reference in New Issue
Block a user