Compare commits

...

6 Commits

Author SHA1 Message Date
James Wampler
cbf1aba219 Fix v-btn-toggle overlap in dialogs; add segment override icon to features grid
Replace v-btn-toggle with manual button pairs in RuleGroupEditor and
FeatureValueEditor — VSlideGroup (used internally) measures zero width
inside teleported dialogs, causing buttons to overlap and show scroll arrows.

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 21:46:52 -07:00
40 changed files with 2351 additions and 1073 deletions

View File

@@ -4,6 +4,11 @@ var postgres = builder.AddPostgres("postgres").WithDataVolume("miccheck-pgdata")
var miccheckDb = postgres.AddDatabase("miccheck");
builder.AddProject<Projects.MicCheck_Api>("api").WithReference(miccheckDb).WaitFor(miccheckDb);
var api = builder.AddProject<Projects.MicCheck_Api>("api").WithReference(miccheckDb).WaitFor(miccheckDb);
builder.AddViteApp("admin", "../admin", "serve")
.WithReference(api)
.WaitFor(api)
.WithExternalHttpEndpoints();
builder.Build().Run();

View File

@@ -2,6 +2,7 @@
<ItemGroup>
<ProjectReference Include="..\api\MicCheck.Api\MicCheck.Api.csproj" />
<ProjectReference Include="..\admin\Admin.esproj" />
</ItemGroup>
<ItemGroup>

View File

@@ -4,5 +4,13 @@
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Dashboard": {
"Frontend": {
"AuthMode": "Unsecured"
},
"Otlp": {
"AuthMode": "Unsecured"
}
}
}

View File

@@ -81,3 +81,7 @@ export async function regenerateInviteLink(organizationId: number): Promise<Invi
export async function acceptInvite(token: string): Promise<void> {
await apiClient.post(`/v1/organisations/invite/${token}/accept`);
}
export async function setPrimaryOrganization(id: number): Promise<void> {
await apiClient.put(`/v1/organisations/${id}/primary`);
}

View File

@@ -29,7 +29,7 @@
</v-tabs>
<v-divider />
<v-card-text class="pa-0" style="height: 480px; overflow-y: auto;">
<v-card-text class="pa-0" style="height: 580px; overflow-y: auto;">
<v-tabs-window v-model="activeTab">
<!-- Value tab -->
<v-tabs-window-item value="value" class="pa-6">
@@ -319,6 +319,7 @@ interface Props {
modelValue: boolean;
feature: FeatureResponse | null;
featureState: FeatureStateResponse | null;
initialTab?: string;
}
const props = defineProps<Props>();
@@ -472,7 +473,7 @@ watch(
() => props.modelValue,
(open) => {
if (open) {
activeTab.value = 'value';
activeTab.value = props.initialTab ?? 'value';
valueErrorMessage.value = null;
settingsErrorMessage.value = null;
segmentsErrorMessage.value = null;

View File

@@ -1,14 +1,24 @@
<template>
<div data-testid="feature-value-editor">
<!-- Type selector -->
<v-btn-toggle
v-model="detectedMode"
density="compact"
data-testid="value-mode-toggle"
>
<v-btn value="text" size="small">Text</v-btn>
<v-btn value="json" size="small">JSON</v-btn>
</v-btn-toggle>
<div class="d-flex mb-3" data-testid="value-mode-toggle">
<v-btn
size="small"
:variant="detectedMode === 'text' ? 'flat' : 'outlined'"
color="primary"
rounded="0"
style="border-radius: 4px 0 0 4px"
@click="detectedMode = 'text'"
>Text</v-btn>
<v-btn
size="small"
:variant="detectedMode === 'json' ? 'flat' : 'outlined'"
color="primary"
rounded="0"
style="border-radius: 0 4px 4px 0; margin-left: -1px"
@click="detectedMode = 'json'"
>JSON</v-btn>
</div>
<!-- JSON mode -->
<v-textarea
@@ -83,11 +93,3 @@ const rules = {
};
</script>
<style scoped>
/* Materio forces square icon-toggle sizing; override for text-label toggles */
:deep(.v-btn-toggle .v-btn) {
block-size: auto !important;
inline-size: auto !important;
padding-inline: 16px !important;
}
</style>

View File

@@ -33,6 +33,7 @@
<v-form ref="createFormRef" @submit.prevent="onCreateTag">
<div class="d-flex align-center ga-2">
<v-text-field
ref="tagLabelInputRef"
v-model="newTagLabel"
label="Tag name"
variant="outlined"
@@ -47,6 +48,7 @@
style="width:36px;height:36px;border:none;padding:2px;cursor:pointer;border-radius:4px"
title="Tag colour"
data-testid="new-tag-color-input"
@blur="focusTagLabel"
/>
<v-btn
icon="ri-check-line"
@@ -99,7 +101,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ref, onMounted, watch, nextTick } from 'vue';
import { listTags, createTag, deleteTag } from '@/api/tags';
import { useContextStore } from '@/stores/context';
import type { TagResponse } from '@/types/api';
@@ -114,6 +116,15 @@ const newTagLabel = ref('');
const newTagColor = ref('#1565C0');
const errorMessage = ref<string | null>(null);
const createFormRef = ref<{ validate: () => Promise<{ valid: boolean }> } | null>(null);
const tagLabelInputRef = ref<{ focus: () => void } | null>(null);
function focusTagLabel(): void {
nextTick(() => tagLabelInputRef.value?.focus());
}
watch(showCreateForm, (open) => {
if (open) focusTagLabel();
});
const rules = {
required: (v: string) => !!v || 'Tag name is required',

View File

@@ -86,14 +86,25 @@ async function fetchEnvironments(projectId: number): Promise<void> {
isLoading.value = true;
try {
environments.value = await listEnvironments(projectId);
if (environments.value.length > 0 && !contextStore.currentEnvironment) {
contextStore.setEnvironment(environments.value[0]);
}
autoSelectEnvironment();
} finally {
isLoading.value = false;
}
}
function autoSelectEnvironment(): void {
if (contextStore.currentEnvironment) {
const fresh = environments.value.find(e => e.id === contextStore.currentEnvironment!.id);
if (fresh) {
contextStore.refreshEnvironment(fresh);
return;
}
}
if (environments.value.length > 0) {
contextStore.setEnvironment(environments.value[0]);
}
}
function onEnvironmentSelected(env: EnvironmentResponse | null): void {
if (env) contextStore.setEnvironment(env);
}
@@ -127,11 +138,12 @@ async function onCreateConfirm(): Promise<void> {
}
watch(
() => contextStore.currentProject,
(project) => {
() => contextStore.currentProject?.id,
(projectId, oldProjectId) => {
if (projectId === oldProjectId) return;
environments.value = [];
contextStore.setEnvironment(null);
if (project) fetchEnvironments(project.id);
if (oldProjectId !== undefined) contextStore.setEnvironment(null);
if (projectId) fetchEnvironments(projectId);
},
{ immediate: true },
);

View File

@@ -67,7 +67,7 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useContextStore } from '@/stores/context';
import { listOrganizations, createOrganization } from '@/api/organizations';
import { listOrganizations, createOrganization, setPrimaryOrganization } from '@/api/organizations';
import type { OrganizationResponse } from '@/types/api';
const contextStore = useContextStore();
@@ -84,6 +84,7 @@ async function fetchOrganizations(): Promise<void> {
isLoading.value = true;
try {
organizations.value = await listOrganizations();
autoSelectOrganization();
} catch {
organizations.value = [];
} finally {
@@ -91,8 +92,35 @@ async function fetchOrganizations(): Promise<void> {
}
}
function onOrgSelected(org: OrganizationResponse | null): void {
function autoSelectOrganization(): void {
if (contextStore.currentOrganization) {
// Refresh stored org with latest data from API (name may have changed)
const fresh = organizations.value.find(o => o.id === contextStore.currentOrganization!.id);
if (fresh) contextStore.refreshOrganization(fresh);
return;
}
const primary = organizations.value.find(o => o.isPrimary);
if (primary) {
contextStore.setOrganization(primary);
return;
}
if (organizations.value.length === 1) {
contextStore.setOrganization(organizations.value[0]);
}
}
async function onOrgSelected(org: OrganizationResponse | null): Promise<void> {
contextStore.setOrganization(org);
if (org) {
try {
await setPrimaryOrganization(org.id);
organizations.value = organizations.value.map(o => ({ ...o, isPrimary: o.id === org.id }));
} catch {
// Non-critical — selection is already saved to localStorage
}
}
}
function openCreateDialog(): void {

View File

@@ -86,11 +86,27 @@ async function fetchProjects(organizationId: number): Promise<void> {
isLoading.value = true;
try {
projects.value = await listProjects(organizationId);
autoSelectProject();
} finally {
isLoading.value = false;
}
}
function autoSelectProject(): void {
if (contextStore.currentProject) {
const fresh = projects.value.find(p => p.id === contextStore.currentProject!.id);
if (fresh) {
contextStore.refreshProject(fresh);
return;
}
contextStore.setProject(null);
return;
}
if (projects.value.length === 1) {
contextStore.setProject(projects.value[0]);
}
}
function onProjectSelected(project: ProjectResponse | null): void {
contextStore.setProject(project);
}
@@ -124,12 +140,11 @@ async function onCreateConfirm(): Promise<void> {
}
watch(
() => contextStore.currentOrganization,
(org) => {
() => contextStore.currentOrganization?.id,
(orgId, oldOrgId) => {
if (orgId === oldOrgId) return;
projects.value = [];
if (org) {
fetchProjects(org.id);
}
if (orgId) fetchProjects(orgId);
},
{ immediate: true },
);

View File

@@ -3,18 +3,26 @@
<!-- AND / OR toggle -->
<div class="d-flex align-center ga-2 mb-3">
<span class="text-body-2 font-weight-medium text-medium-emphasis">Match</span>
<v-btn-toggle
:model-value="rule.type"
density="compact"
mandatory
color="primary"
variant="outlined"
data-testid="rule-type-toggle"
@update:model-value="onTypeChange"
>
<v-btn value="All" size="small" data-testid="rule-type-and">ALL (AND)</v-btn>
<v-btn value="Any" size="small" data-testid="rule-type-or">ANY (OR)</v-btn>
</v-btn-toggle>
<div class="d-flex" data-testid="rule-type-toggle">
<v-btn
size="small"
:variant="rule.type === 'All' ? 'flat' : 'outlined'"
color="primary"
rounded="0"
style="border-radius: 4px 0 0 4px"
data-testid="rule-type-and"
@click="onTypeChange('All')"
>ALL (AND)</v-btn>
<v-btn
size="small"
:variant="rule.type === 'Any' ? 'flat' : 'outlined'"
color="primary"
rounded="0"
style="border-radius: 0 4px 4px 0; margin-left: -1px"
data-testid="rule-type-or"
@click="onTypeChange('Any')"
>ANY (OR)</v-btn>
</div>
<v-spacer />
<v-btn
v-if="removable"

View File

@@ -46,6 +46,21 @@ export const useContextStore = defineStore('context', () => {
}
}
function refreshOrganization(org: OrganizationResponse): void {
currentOrganization.value = org;
localStorage.setItem(STORAGE_KEYS.organization, JSON.stringify(org));
}
function refreshProject(project: ProjectResponse): void {
currentProject.value = project;
localStorage.setItem(STORAGE_KEYS.project, JSON.stringify(project));
}
function refreshEnvironment(environment: EnvironmentResponse): void {
currentEnvironment.value = environment;
localStorage.setItem(STORAGE_KEYS.environment, JSON.stringify(environment));
}
function loadFromStorage(): void {
try {
const orgRaw = localStorage.getItem(STORAGE_KEYS.organization);
@@ -79,6 +94,9 @@ export const useContextStore = defineStore('context', () => {
setOrganization,
setProject,
setEnvironment,
refreshOrganization,
refreshProject,
refreshEnvironment,
loadFromStorage,
clearContext,
};

View File

@@ -64,6 +64,7 @@ export interface OrganizationResponse {
id: number;
name: string;
createdAt: string;
isPrimary: boolean;
}
export interface CreateOrganizationRequest {

View File

@@ -53,7 +53,26 @@
<!-- Name + description -->
<template #item.name="{ item }: { item: FeatureResponse }">
<div>
<span class="text-body-2 font-weight-medium">{{ item.name }}</span>
<div class="d-flex align-center ga-1">
<span class="text-subtitle-1 font-weight-bold">{{ item.name }}</span>
<v-tooltip
v-if="(featureSegmentsMap.get(item.id) ?? []).length > 0"
text="Has segment overrides"
location="top"
>
<template #activator="{ props: tooltipProps }">
<v-btn
v-bind="tooltipProps"
icon="ri-donut-chart-fill"
size="small"
variant="text"
color="secondary"
:data-testid="`segment-override-icon-${item.id}`"
@click.stop="openDetailOnSegments(item)"
/>
</template>
</v-tooltip>
</div>
<p v-if="item.description" class="text-caption text-medium-emphasis mb-0">
{{ item.description }}
</p>
@@ -158,6 +177,7 @@
v-model="showDetail"
:feature="selectedFeature"
:feature-state="selectedFeatureState"
:initial-tab="detailInitialTab"
@updated="onFeatureUpdated"
@deleted="onFeatureDeleted"
@state-updated="onStateUpdated"
@@ -236,6 +256,7 @@ function showError(message: string): void {
const showDialog = ref(false);
const showDetail = ref(false);
const selectedFeature = ref<FeatureResponse | null>(null);
const detailInitialTab = ref('value');
// Delete dialog state
const showDeleteDialog = ref(false);
@@ -333,6 +354,13 @@ function openCreateDialog(): void {
}
function onRowClick(_event: Event, { item }: { item: FeatureResponse }): void {
detailInitialTab.value = 'value';
selectedFeature.value = item;
showDetail.value = true;
}
function openDetailOnSegments(item: FeatureResponse): void {
detailInitialTab.value = 'segments';
selectedFeature.value = item;
showDetail.value = true;
}

View File

@@ -21,8 +21,8 @@
</v-alert>
<v-form ref="formRef" @submit.prevent="onSubmit" data-testid="register-form">
<v-row>
<v-col cols="12" sm="6" class="pb-0">
<v-row no-gutters class="mb-3">
<v-col cols="12" sm="6" class="pr-sm-2">
<v-text-field
v-model="firstName"
label="First name"
@@ -30,10 +30,11 @@
variant="outlined"
density="comfortable"
:rules="[rules.required]"
:hide-details="false"
data-testid="first-name-input"
/>
</v-col>
<v-col cols="12" sm="6" class="pb-0">
<v-col cols="12" sm="6" class="pl-sm-2">
<v-text-field
v-model="lastName"
label="Last name"
@@ -41,6 +42,7 @@
variant="outlined"
density="comfortable"
:rules="[rules.required]"
:hide-details="false"
data-testid="last-name-input"
/>
</v-col>
@@ -53,8 +55,9 @@
autocomplete="email"
variant="outlined"
density="comfortable"
class="mb-3"
:rules="[rules.required, rules.email]"
:hide-details="false"
class="mb-3"
data-testid="email-input"
/>
@@ -64,8 +67,9 @@
autocomplete="organization"
variant="outlined"
density="comfortable"
class="mb-3"
:rules="[rules.required]"
:hide-details="false"
class="mb-3"
data-testid="org-name-input"
/>
@@ -76,10 +80,11 @@
autocomplete="new-password"
variant="outlined"
density="comfortable"
class="mb-3"
:rules="[rules.required, rules.minLength]"
:append-inner-icon="showPassword ? 'ri-eye-off-line' : 'ri-eye-line'"
@click:append-inner="showPassword = !showPassword"
:hide-details="false"
class="mb-3"
data-testid="password-input"
/>
@@ -90,8 +95,9 @@
autocomplete="new-password"
variant="outlined"
density="comfortable"
class="mb-4"
:rules="[rules.required, rules.passwordMatch]"
:hide-details="false"
class="mb-4"
data-testid="confirm-password-input"
/>
@@ -184,4 +190,8 @@ async function onSubmit(): Promise<void> {
.auth-bg {
background: rgb(var(--v-theme-background));
}
:deep(.v-input__details) {
min-height: 22px;
}
</style>

View File

@@ -56,13 +56,6 @@ describe('EnvironmentTabBar', () => {
expect(envApi.listEnvironments).not.toHaveBeenCalled();
});
it('ThenNoProjectMessageIsShown', async () => {
const wrapper = mountComponent();
await flushPromises();
expect(wrapper.find('[data-testid="env-no-project-message"]').exists()).toBe(true);
});
it('ThenCreateEnvironmentButtonIsDisabled', async () => {
const wrapper = mountComponent();
await flushPromises();
@@ -84,7 +77,7 @@ describe('EnvironmentTabBar', () => {
expect(envApi.listEnvironments).toHaveBeenCalledWith(mockProject.id);
});
it('ThenEnvironmentChipsAreRendered', async () => {
it('ThenEnvironmentSelectIsEnabled', async () => {
jest.mocked(envApi.listEnvironments).mockResolvedValue(mockEnvironments);
const contextStore = useContextStore();
@@ -93,8 +86,7 @@ describe('EnvironmentTabBar', () => {
contextStore.setProject(mockProject);
await flushPromises();
expect(wrapper.find('[data-testid="env-chip-development"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="env-chip-production"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="env-select"]').attributes('disabled')).toBeUndefined();
});
it('ThenFirstEnvironmentIsAutoSelected', async () => {
@@ -122,7 +114,7 @@ describe('EnvironmentTabBar', () => {
});
});
describe('WhenEnvironmentChipIsClicked', () => {
describe('WhenEnvironmentIsSelected', () => {
it('ThenContextStoreIsUpdatedWithSelectedEnvironment', async () => {
jest.mocked(envApi.listEnvironments).mockResolvedValue(mockEnvironments);
@@ -132,7 +124,7 @@ describe('EnvironmentTabBar', () => {
contextStore.setProject(mockProject);
await flushPromises();
await wrapper.findComponent({ name: 'VChipGroup' }).vm.$emit('update:modelValue', 1);
await wrapper.findComponent({ name: 'VSelect' }).vm.$emit('update:modelValue', mockEnvironments[1]);
expect(contextStore.currentEnvironment).toEqual(mockEnvironments[1]);
});
@@ -158,16 +150,49 @@ describe('EnvironmentTabBar', () => {
});
describe('WhenApiReturnsNoEnvironments', () => {
it('ThenEmptyMessageIsDisplayed', async () => {
it('ThenNoEnvironmentIsAutoSelected', async () => {
jest.mocked(envApi.listEnvironments).mockResolvedValue([]);
const contextStore = useContextStore();
const wrapper = mountComponent();
mountComponent();
contextStore.setProject(mockProject);
await flushPromises();
expect(wrapper.find('[data-testid="env-empty-message"]').exists()).toBe(true);
expect(contextStore.currentEnvironment).toBeNull();
});
});
describe('WhenStoredEnvironmentExistsInContextStore', () => {
it('ThenStoredEnvironmentIsRestoredAfterFetch', async () => {
jest.mocked(envApi.listEnvironments).mockResolvedValue(mockEnvironments);
const contextStore = useContextStore();
contextStore.setProject(mockProject);
contextStore.setEnvironment(mockEnvironments[1]);
mountComponent();
await flushPromises();
expect(contextStore.currentEnvironment).toEqual(mockEnvironments[1]);
});
it('ThenEnvironmentIsClearedWhenProjectChanges', async () => {
jest.mocked(envApi.listEnvironments).mockResolvedValue(mockEnvironments);
const contextStore = useContextStore();
contextStore.setProject(mockProject);
contextStore.setEnvironment(mockEnvironments[0]);
mountComponent();
await flushPromises();
const newProject: ProjectResponse = { ...mockProject, id: 20, name: 'Mobile' };
jest.mocked(envApi.listEnvironments).mockResolvedValue([]);
contextStore.setProject(newProject);
await flushPromises();
expect(contextStore.currentEnvironment).toBeNull();
});
});

View File

@@ -18,8 +18,8 @@ import * as orgsApi from '@/api/organizations';
const dialogStub = { template: '<div><slot /></div>' };
const mockOrgs: OrganizationResponse[] = [
{ id: 1, name: 'Acme Corp', createdAt: '2026-01-01T00:00:00Z' },
{ id: 2, name: 'Globex', createdAt: '2026-01-01T00:00:00Z' },
{ id: 1, name: 'Acme Corp', createdAt: '2026-01-01T00:00:00Z', isPrimary: false },
{ id: 2, name: 'Globex', createdAt: '2026-01-01T00:00:00Z', isPrimary: false },
];
function mountComponent() {
@@ -132,7 +132,7 @@ describe('OrgSelector', () => {
});
describe('WhenCreateOrganizationIsConfirmed', () => {
const newOrg: OrganizationResponse = { id: 3, name: 'New Org', createdAt: '2026-04-13T00:00:00Z' };
const newOrg: OrganizationResponse = { id: 3, name: 'New Org', createdAt: '2026-04-13T00:00:00Z', isPrimary: false };
it('ThenCreateOrganizationApiIsCalled', async () => {
jest.mocked(orgsApi.listOrganizations).mockResolvedValue([]);

View File

@@ -17,7 +17,7 @@ import * as projectsApi from '@/api/projects';
const dialogStub = { template: '<div><slot /></div>' };
const mockOrg: OrganizationResponse = { id: 1, name: 'Acme', createdAt: '2026-01-01T00:00:00Z' };
const mockOrg: OrganizationResponse = { id: 1, name: 'Acme', createdAt: '2026-01-01T00:00:00Z', isPrimary: false };
const mockProjects: ProjectResponse[] = [
{ id: 10, name: 'Website', organizationId: 1, hideDisabledFlags: false, createdAt: '2026-01-01T00:00:00Z' },
{ id: 11, name: 'Mobile App', organizationId: 1, hideDisabledFlags: false, createdAt: '2026-01-01T00:00:00Z' },
@@ -104,6 +104,54 @@ describe('ProjectSelector', () => {
});
});
describe('WhenStoredProjectExistsInContextStore', () => {
it('ThenStoredProjectIsRestoredAfterFetch', async () => {
jest.mocked(projectsApi.listProjects).mockResolvedValue(mockProjects);
const contextStore = useContextStore();
contextStore.setOrganization(mockOrg);
contextStore.setProject(mockProjects[1]);
mountComponent();
await flushPromises();
expect(contextStore.currentProject).toEqual(mockProjects[1]);
});
it('ThenProjectIsClearedWhenNotFoundInNewOrg', async () => {
jest.mocked(projectsApi.listProjects).mockResolvedValue(mockProjects);
const contextStore = useContextStore();
contextStore.setOrganization(mockOrg);
contextStore.setProject(mockProjects[0]);
mountComponent();
await flushPromises();
const newOrg: OrganizationResponse = { id: 2, name: 'Globex', createdAt: '2026-01-01T00:00:00Z', isPrimary: false };
jest.mocked(projectsApi.listProjects).mockResolvedValue([]);
contextStore.setOrganization(newOrg);
await flushPromises();
expect(contextStore.currentProject).toBeNull();
});
});
describe('WhenSingleProjectIsAvailable', () => {
it('ThenItIsAutoSelected', async () => {
const singleProject = mockProjects[0];
jest.mocked(projectsApi.listProjects).mockResolvedValue([singleProject]);
const contextStore = useContextStore();
mountComponent();
contextStore.setOrganization(mockOrg);
await flushPromises();
expect(contextStore.currentProject).toEqual(singleProject);
});
});
describe('WhenOrganizationChanges', () => {
it('ThenProjectsAreReloaded', async () => {
jest.mocked(projectsApi.listProjects).mockResolvedValue(mockProjects);
@@ -114,7 +162,7 @@ describe('ProjectSelector', () => {
contextStore.setOrganization(mockOrg);
await flushPromises();
const newOrg: OrganizationResponse = { id: 2, name: 'Globex', createdAt: '2026-01-01T00:00:00Z' };
const newOrg: OrganizationResponse = { id: 2, name: 'Globex', createdAt: '2026-01-01T00:00:00Z', isPrimary: false };
contextStore.setOrganization(newOrg);
await flushPromises();

View File

@@ -46,7 +46,7 @@ describe('FeatureDetail', () => {
setActivePinia(createPinia());
jest.clearAllMocks();
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '', isPrimary: false });
contextStore.setProject(mockProject);
contextStore.setEnvironment({ id: 100, name: 'Development', apiKey: 'env-dev', projectId: 10, createdAt: '' });
});

View File

@@ -39,7 +39,7 @@ describe('FeatureDialog', () => {
setActivePinia(createPinia());
jest.clearAllMocks();
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '', isPrimary: false });
contextStore.setProject(mockProject);
});

View File

@@ -33,7 +33,7 @@ describe('TagsChipInput', () => {
setActivePinia(createPinia());
jest.clearAllMocks();
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '', isPrimary: false });
contextStore.setProject(mockProject);
});

View File

@@ -60,7 +60,7 @@ describe('IdentityDetail', () => {
setActivePinia(createPinia());
jest.clearAllMocks();
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '', isPrimary: false });
contextStore.setProject(mockProject);
contextStore.setEnvironment(mockEnv);

View File

@@ -42,7 +42,7 @@ describe('SegmentEditor', () => {
setActivePinia(createPinia());
jest.clearAllMocks();
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '', isPrimary: false });
contextStore.setProject(mockProject);
});

View File

@@ -3,7 +3,7 @@ import { setActivePinia, createPinia } from 'pinia';
import { useContextStore } from '@/stores/context';
import type { OrganizationResponse, ProjectResponse, EnvironmentResponse } from '@/types/api';
const mockOrg: OrganizationResponse = { id: 1, name: 'Acme', createdAt: '2026-01-01T00:00:00Z' };
const mockOrg: OrganizationResponse = { id: 1, name: 'Acme', createdAt: '2026-01-01T00:00:00Z', isPrimary: false };
const mockProject: ProjectResponse = {
id: 10,
name: 'Website',
@@ -119,6 +119,57 @@ describe('useContextStore', () => {
});
});
describe('WhenRefreshOrganizationIsCalled', () => {
it('ThenOrgIsUpdatedWithoutClearingProjectOrEnvironment', () => {
const store = useContextStore();
store.setOrganization(mockOrg);
store.setProject(mockProject);
store.setEnvironment(mockEnv);
const updatedOrg = { ...mockOrg, name: 'Acme Updated' };
store.refreshOrganization(updatedOrg);
expect(store.currentOrganization).toEqual(updatedOrg);
expect(store.currentProject).toEqual(mockProject);
expect(store.currentEnvironment).toEqual(mockEnv);
});
it('ThenOrgIsPersistedToLocalStorage', () => {
const store = useContextStore();
const updatedOrg = { ...mockOrg, name: 'Acme Updated' };
store.refreshOrganization(updatedOrg);
const stored = JSON.parse(localStorage.getItem('mic_ctx_organization')!);
expect(stored).toEqual(updatedOrg);
});
});
describe('WhenRefreshProjectIsCalled', () => {
it('ThenProjectIsUpdatedWithoutClearingEnvironment', () => {
const store = useContextStore();
store.setProject(mockProject);
store.setEnvironment(mockEnv);
const updatedProject = { ...mockProject, name: 'Website Updated' };
store.refreshProject(updatedProject);
expect(store.currentProject).toEqual(updatedProject);
expect(store.currentEnvironment).toEqual(mockEnv);
});
});
describe('WhenRefreshEnvironmentIsCalled', () => {
it('ThenEnvironmentIsUpdatedAndPersisted', () => {
const store = useContextStore();
const updatedEnv = { ...mockEnv, name: 'Production Updated' };
store.refreshEnvironment(updatedEnv);
expect(store.currentEnvironment).toEqual(updatedEnv);
const stored = JSON.parse(localStorage.getItem('mic_ctx_environment')!);
expect(stored).toEqual(updatedEnv);
});
});
describe('WhenClearContextIsCalled', () => {
it('ThenAllContextIsRemovedFromStateAndStorage', () => {
const store = useContextStore();

View File

@@ -23,12 +23,12 @@ const mockLogs: AuditLogResponse[] = [
{
id: 1, resourceType: 'Feature', resourceId: '42', action: 'Created',
changes: null, organizationId: 1, projectId: 10, environmentId: null,
actorUserId: 5, createdAt: '2026-01-10T10:00:00Z',
actorUserId: 5, actorUserName: 'Alice', createdAt: '2026-01-10T10:00:00Z',
},
{
id: 2, resourceType: 'FeatureState', resourceId: '99', action: 'Updated',
changes: '{"enabled":true}', organizationId: 1, projectId: 10, environmentId: 100,
actorUserId: 5, createdAt: '2026-01-11T11:00:00Z',
actorUserId: 5, actorUserName: 'Alice', createdAt: '2026-01-11T11:00:00Z',
},
];
@@ -58,7 +58,7 @@ describe('AuditLogsView', () => {
});
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '', isPrimary: false });
contextStore.setProject(mockProject);
mountView();
@@ -76,7 +76,7 @@ describe('AuditLogsView', () => {
});
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '', isPrimary: false });
contextStore.setProject(mockProject);
const wrapper = mountView();
@@ -95,7 +95,7 @@ describe('AuditLogsView', () => {
});
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '', isPrimary: false });
contextStore.setProject(mockProject);
const wrapper = mountView();
@@ -120,7 +120,7 @@ describe('AuditLogsView', () => {
});
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '', isPrimary: false });
contextStore.setProject(mockProject);
const wrapper = mountView();
@@ -145,7 +145,7 @@ describe('AuditLogsView', () => {
});
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '', isPrimary: false });
contextStore.setProject(mockProject);
const wrapper = mountView();
@@ -170,7 +170,7 @@ describe('AuditLogsView', () => {
});
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '', isPrimary: false });
contextStore.setProject(mockProject);
const wrapper = mountView();
@@ -195,7 +195,7 @@ describe('AuditLogsView', () => {
});
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '', isPrimary: false });
contextStore.setProject(mockProject);
const wrapper = mountView();

View File

@@ -58,7 +58,7 @@ describe('FeaturesView', () => {
jest.mocked(featureStatesApi.listFeatureStates).mockResolvedValue(mockStates);
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '', isPrimary: false });
contextStore.setProject(mockProject);
contextStore.setEnvironment(mockEnv);
@@ -74,7 +74,7 @@ describe('FeaturesView', () => {
jest.mocked(featureStatesApi.listFeatureStates).mockResolvedValue(mockStates);
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '', isPrimary: false });
contextStore.setProject(mockProject);
const wrapper = mountView();
@@ -92,7 +92,7 @@ describe('FeaturesView', () => {
jest.mocked(featureStatesApi.patchFeatureState).mockResolvedValue({ ...mockStates[0], enabled: true });
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '', isPrimary: false });
contextStore.setProject(mockProject);
contextStore.setEnvironment(mockEnv);
@@ -115,7 +115,7 @@ describe('FeaturesView', () => {
jest.mocked(featureStatesApi.listFeatureStates).mockResolvedValue([]);
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '', isPrimary: false });
contextStore.setProject(mockProject);
const wrapper = mountView();

View File

@@ -57,7 +57,7 @@ describe('IdentitiesView', () => {
});
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '', isPrimary: false });
contextStore.setProject(mockProject);
contextStore.setEnvironment(mockEnv);
@@ -73,7 +73,7 @@ describe('IdentitiesView', () => {
});
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '', isPrimary: false });
contextStore.setProject(mockProject);
contextStore.setEnvironment(mockEnv);
@@ -91,7 +91,7 @@ describe('IdentitiesView', () => {
});
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '', isPrimary: false });
contextStore.setProject(mockProject);
contextStore.setEnvironment(mockEnv);
@@ -115,7 +115,7 @@ describe('IdentitiesView', () => {
});
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '', isPrimary: false });
contextStore.setProject(mockProject);
contextStore.setEnvironment(mockEnv);
@@ -137,7 +137,7 @@ describe('IdentitiesView', () => {
});
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '', isPrimary: false });
contextStore.setProject(mockProject);
contextStore.setEnvironment(mockEnv);
@@ -163,7 +163,7 @@ describe('IdentitiesView', () => {
});
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '', isPrimary: false });
contextStore.setProject(mockProject);
contextStore.setEnvironment(mockEnv);

View File

@@ -44,7 +44,7 @@ describe('ProfileView', () => {
it('ThenOrgNameIsDisplayedWhenSelected', () => {
const { wrapper } = mountView();
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme Corp', createdAt: '' });
contextStore.setOrganization({ id: 1, name: 'Acme Corp', createdAt: '', isPrimary: false });
expect(wrapper.find('[data-testid="profile-org-name"]').exists()).toBe(false); // org name shown in v-if block
});

View File

@@ -61,7 +61,7 @@ describe('SegmentsView', () => {
jest.mocked(segmentsApi.listSegments).mockResolvedValue(mockSegments);
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '', isPrimary: false });
contextStore.setProject(mockProject);
mountView();
@@ -74,7 +74,7 @@ describe('SegmentsView', () => {
jest.mocked(segmentsApi.listSegments).mockResolvedValue(mockSegments);
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '', isPrimary: false });
contextStore.setProject(mockProject);
const wrapper = mountView();
@@ -88,7 +88,7 @@ describe('SegmentsView', () => {
jest.mocked(segmentsApi.listSegments).mockResolvedValue(mockSegments);
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '', isPrimary: false });
contextStore.setProject(mockProject);
const wrapper = mountView();
@@ -104,7 +104,7 @@ describe('SegmentsView', () => {
jest.mocked(segmentsApi.listSegments).mockResolvedValue([]);
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '', isPrimary: false });
contextStore.setProject(mockProject);
const wrapper = mountView();
@@ -123,7 +123,7 @@ describe('SegmentsView', () => {
jest.mocked(segmentsApi.listSegments).mockResolvedValue(mockSegments);
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '', isPrimary: false });
contextStore.setProject(mockProject);
const wrapper = mountView();
@@ -143,7 +143,7 @@ describe('SegmentsView', () => {
jest.mocked(segmentsApi.deleteSegment).mockResolvedValue(undefined);
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '', isPrimary: false });
contextStore.setProject(mockProject);
const wrapper = mountView();
@@ -172,7 +172,7 @@ describe('SegmentsView', () => {
jest.mocked(segmentsApi.deleteSegment).mockResolvedValue(undefined);
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '', isPrimary: false });
contextStore.setProject(mockProject);
const wrapper = mountView();
@@ -196,7 +196,7 @@ describe('SegmentsView', () => {
jest.mocked(segmentsApi.listSegments).mockResolvedValue([]);
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '', isPrimary: false });
contextStore.setProject(mockProject);
const wrapper = mountView();
@@ -214,7 +214,7 @@ describe('SegmentsView', () => {
jest.mocked(segmentsApi.listSegments).mockResolvedValue([...mockSegments]);
const contextStore = useContextStore();
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '' });
contextStore.setOrganization({ id: 1, name: 'Acme', createdAt: '', isPrimary: false });
contextStore.setProject(mockProject);
const wrapper = mountView();

View File

@@ -26,7 +26,7 @@ import * as environmentsApi from '@/api/environments';
import * as apiKeysApi from '@/api/apiKeys';
import * as webhooksApi from '@/api/webhooks';
const mockOrg: OrganizationResponse = { id: 1, name: 'Acme', createdAt: '2026-01-01T00:00:00Z' };
const mockOrg: OrganizationResponse = { id: 1, name: 'Acme', createdAt: '2026-01-01T00:00:00Z', isPrimary: false };
const mockProject: ProjectResponse = { id: 10, name: 'Website', organizationId: 1, hideDisabledFlags: false, createdAt: '2026-01-01T00:00:00Z' };
const mockEnvs: EnvironmentResponse[] = [
{ id: 100, name: 'Development', apiKey: 'env-dev', projectId: 10, createdAt: '2026-01-01T00:00:00Z' },

View File

@@ -10,5 +10,6 @@ public class OrganizationUserConfiguration : IEntityTypeConfiguration<Organizati
{
builder.HasKey(ou => new { ou.OrganizationId, ou.UserId });
builder.Property(ou => ou.Role).IsRequired();
builder.Property(ou => ou.IsPrimary).IsRequired().HasDefaultValue(false);
}
}

View File

@@ -0,0 +1,941 @@
// <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("20260611164933_AddOrganizationUserIsPrimary")]
partial class AddOrganizationUserIsPrimary
{
/// <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.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.Common.Security.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.Common.Security.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>("InviteToken")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.HasKey("Id");
b.HasIndex("InviteToken")
.IsUnique()
.HasFilter("\"InviteToken\" IS NOT NULL");
b.ToTable("Organizations");
});
modelBuilder.Entity("MicCheck.Api.Organizations.OrganizationUser", b =>
{
b.Property<int>("OrganizationId")
.HasColumnType("integer");
b.Property<int>("UserId")
.HasColumnType("integer");
b.Property<bool>("IsPrimary")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
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.Common.Security.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
}
}
}

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MicCheck.Api.Migrations
{
/// <inheritdoc />
public partial class AddOrganizationUserIsPrimary : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsPrimary",
table: "OrganizationUsers",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsPrimary",
table: "OrganizationUsers");
}
}
}

View File

@@ -3,11 +3,12 @@ namespace MicCheck.Api.Organizations;
public record OrganizationResponse(
int Id,
string Name,
DateTimeOffset CreatedAt
DateTimeOffset CreatedAt,
bool IsPrimary
)
{
public static OrganizationResponse From(Organization org) => new(
org.Id, org.Name, org.CreatedAt);
public static OrganizationResponse From(Organization org, bool isPrimary = false) => new(
org.Id, org.Name, org.CreatedAt, isPrimary);
}
public record OrganizationMemberResponse(

View File

@@ -1,17 +1,21 @@
using MicCheck.Api.Audit;
using MicCheck.Api.Data;
using MicCheck.Api.Users;
using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Organizations;
public class OrganizationService(MicCheckDbContext db, AuditService auditService)
{
public async Task<IReadOnlyList<Organization>> ListForUserAsync(int userId, CancellationToken ct = default)
public async Task<IReadOnlyList<(Organization Org, bool IsPrimary)>> ListForUserAsync(int userId, CancellationToken ct = default)
{
return await db.Organizations
.Where(o => o.Members.Any(m => m.UserId == userId))
.ToListAsync(ct);
var rows = await (
from org in db.Organizations
join member in db.OrganizationUsers on org.Id equals member.OrganizationId
where member.UserId == userId
select new { org, member.IsPrimary }
).ToListAsync(ct);
return rows.Select(r => (r.org, r.IsPrimary)).ToList();
}
public async Task<Organization?> FindByIdAsync(int id, CancellationToken ct = default)
@@ -21,6 +25,8 @@ public class OrganizationService(MicCheckDbContext db, AuditService auditService
public async Task<Organization> CreateAsync(string name, int creatorUserId, CancellationToken ct = default)
{
var hasExistingOrg = await db.OrganizationUsers.AnyAsync(ou => ou.UserId == creatorUserId, ct);
var org = new Organization
{
Name = name,
@@ -29,7 +35,8 @@ public class OrganizationService(MicCheckDbContext db, AuditService auditService
org.Members.Add(new OrganizationUser
{
UserId = creatorUserId,
Role = OrganizationRole.Admin
Role = OrganizationRole.Admin,
IsPrimary = !hasExistingOrg
});
db.Organizations.Add(org);
await db.SaveChangesAsync(ct);
@@ -39,6 +46,20 @@ public class OrganizationService(MicCheckDbContext db, AuditService auditService
return org;
}
public async Task SetPrimaryAsync(int organizationId, int userId, CancellationToken ct = default)
{
var member = await db.OrganizationUsers
.FirstOrDefaultAsync(ou => ou.OrganizationId == organizationId && ou.UserId == userId, ct)
?? throw new KeyNotFoundException($"User is not a member of organization {organizationId}.");
await db.OrganizationUsers
.Where(ou => ou.UserId == userId && ou.IsPrimary)
.ExecuteUpdateAsync(s => s.SetProperty(ou => ou.IsPrimary, false), ct);
member.IsPrimary = true;
await db.SaveChangesAsync(ct);
}
public async Task<Organization> UpdateAsync(int id, string name, CancellationToken ct = default)
{
var org = await db.Organizations.FirstOrDefaultAsync(o => o.Id == id, ct)
@@ -129,8 +150,7 @@ public class OrganizationService(MicCheckDbContext db, AuditService auditService
public async Task<Organization?> FindByInviteTokenAsync(string token, CancellationToken ct = default) =>
await db.Organizations.FirstOrDefaultAsync(o => o.InviteToken == token, ct);
public async Task<IReadOnlyList<InviteByEmailResult>> InviteUsersByEmailAsync(
int organizationId, IReadOnlyList<InviteByEmailEntry> invites, CancellationToken ct = default)
public async Task<IReadOnlyList<InviteByEmailResult>> InviteUsersByEmailAsync(int organizationId, IReadOnlyList<InviteByEmailEntry> invites, CancellationToken ct = default)
{
var results = new List<InviteByEmailResult>();

View File

@@ -7,6 +7,7 @@ public class OrganizationUser
public int OrganizationId { get; init; }
public int UserId { get; init; }
public OrganizationRole Role { get; set; }
public bool IsPrimary { get; set; }
public User User { get; init; } = null!;
}

View File

@@ -22,7 +22,8 @@ public class OrganizationsController(OrganizationService organizationService, We
if (userId is null) return Unauthorized();
var all = await organizationService.ListForUserAsync(userId.Value, ct);
var paged = all.Skip((page - 1) * pageSize).Take(pageSize).Select(OrganizationResponse.From).ToList();
var paged = all.Skip((page - 1) * pageSize).Take(pageSize)
.Select(x => OrganizationResponse.From(x.Org, x.IsPrimary)).ToList();
return Ok(new PaginatedResponse<OrganizationResponse>(all.Count, null, null, paged));
}
@@ -57,6 +58,23 @@ public class OrganizationsController(OrganizationService organizationService, We
return Ok(OrganizationResponse.From(updated));
}
[HttpPut("{id}/primary")]
public async Task<IActionResult> SetPrimary(int id, CancellationToken ct)
{
var userId = GetCurrentUserId();
if (userId is null) return Unauthorized();
try
{
await organizationService.SetPrimaryAsync(id, userId.Value, ct);
return NoContent();
}
catch (KeyNotFoundException)
{
return NotFound();
}
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(int id, CancellationToken ct)
{

View File

@@ -5,15 +5,9 @@ using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Segments;
public record SegmentConditionDefinition(
string Property,
SegmentConditionOperator Operator,
string Value);
public record SegmentConditionDefinition(string Property, SegmentConditionOperator Operator, string Value);
public record SegmentRuleDefinition(
SegmentRuleType Type,
IReadOnlyList<SegmentConditionDefinition> Conditions,
IReadOnlyList<SegmentRuleDefinition>? ChildRules = null);
public record SegmentRuleDefinition(SegmentRuleType Type, IReadOnlyList<SegmentConditionDefinition> Conditions, IReadOnlyList<SegmentRuleDefinition>? ChildRules = null);
public class SegmentService(MicCheckDbContext db, AuditService auditService)
{
@@ -37,11 +31,7 @@ public class SegmentService(MicCheckDbContext db, AuditService auditService)
.FirstOrDefaultAsync(s => s.Id == id, ct);
}
public async Task<Segment> CreateAsync(
int projectId,
string name,
IReadOnlyList<SegmentRuleDefinition> rules,
CancellationToken ct = default)
public async Task<Segment> CreateAsync(int projectId, string name, IReadOnlyList<SegmentRuleDefinition> rules, CancellationToken ct = default)
{
var count = await db.Segments.CountAsync(s => s.ProjectId == projectId, ct);
if (count >= MaxSegmentsPerProject)
@@ -75,11 +65,7 @@ public class SegmentService(MicCheckDbContext db, AuditService auditService)
return segment;
}
public async Task<Segment> UpdateAsync(
int id,
string name,
IReadOnlyList<SegmentRuleDefinition> rules,
CancellationToken ct = default)
public async Task<Segment> UpdateAsync(int id, string name, IReadOnlyList<SegmentRuleDefinition> rules, CancellationToken ct = default)
{
var segment = await db.Segments
.Include(s => s.Rules)