diff --git a/src/admin/src/api/apiKeys.ts b/src/admin/src/api/apiKeys.ts new file mode 100644 index 0000000..75aa472 --- /dev/null +++ b/src/admin/src/api/apiKeys.ts @@ -0,0 +1,24 @@ +import apiClient from './client'; +import type { ApiKeyResponse, CreateApiKeyRequest, CreateApiKeyResponse } from '@/types/api'; + +export async function listApiKeys(organizationId: number): Promise { + const { data } = await apiClient.get( + `/v1/organisations/${organizationId}/api-keys`, + ); + return data; +} + +export async function createApiKey( + organizationId: number, + request: CreateApiKeyRequest, +): Promise { + const { data } = await apiClient.post( + `/v1/organisations/${organizationId}/api-keys`, + request, + ); + return data; +} + +export async function deleteApiKey(organizationId: number, keyId: number): Promise { + await apiClient.delete(`/v1/organisations/${organizationId}/api-keys/${keyId}`); +} diff --git a/src/admin/src/api/segments.ts b/src/admin/src/api/segments.ts index 16ac5d1..32a4b05 100644 --- a/src/admin/src/api/segments.ts +++ b/src/admin/src/api/segments.ts @@ -3,11 +3,15 @@ import type { SegmentResponse, CreateSegmentRequest, UpdateSegmentRequest, + PaginatedResponse, } from '@/types/api'; -export async function listSegments(projectId: number): Promise { - const { data } = await apiClient.get(`/v1/projects/${projectId}/segments`); - return data; +export async function listSegments(projectId: number, page = 1, pageSize = 100): Promise { + const { data } = await apiClient.get>( + `/v1/projects/${projectId}/segments`, + { params: { page, pageSize } }, + ); + return data.results; } export async function getSegment(projectId: number, id: number): Promise { diff --git a/src/admin/src/components/nav/EnvironmentTabBar.vue b/src/admin/src/components/nav/EnvironmentTabBar.vue index dc9dd76..868bc6e 100644 --- a/src/admin/src/components/nav/EnvironmentTabBar.vue +++ b/src/admin/src/components/nav/EnvironmentTabBar.vue @@ -43,13 +43,59 @@ > Select a project first.

+ + + Create environment + + + + + + Create environment + + + + + + Cancel + + Create + + + + diff --git a/src/admin/src/components/nav/ProjectSelector.vue b/src/admin/src/components/nav/ProjectSelector.vue index 8396cac..54de5d3 100644 --- a/src/admin/src/components/nav/ProjectSelector.vue +++ b/src/admin/src/components/nav/ProjectSelector.vue @@ -17,13 +17,59 @@ data-testid="project-select" @update:model-value="onProjectSelected" /> + + + Create project + + + + + + Create project + + + + + + Cancel + + Create + + + + diff --git a/src/admin/src/views/ProfileView.vue b/src/admin/src/views/ProfileView.vue index 50b7a99..2fbb88f 100644 --- a/src/admin/src/views/ProfileView.vue +++ b/src/admin/src/views/ProfileView.vue @@ -1,17 +1,78 @@ - diff --git a/src/admin/src/views/SettingsView.vue b/src/admin/src/views/SettingsView.vue index 7fbfa9b..576fe50 100644 --- a/src/admin/src/views/SettingsView.vue +++ b/src/admin/src/views/SettingsView.vue @@ -1,17 +1,938 @@ - diff --git a/src/admin/tests/unit/components/EnvironmentTabBar.spec.ts b/src/admin/tests/unit/components/EnvironmentTabBar.spec.ts index fa50a34..e2f3272 100644 --- a/src/admin/tests/unit/components/EnvironmentTabBar.spec.ts +++ b/src/admin/tests/unit/components/EnvironmentTabBar.spec.ts @@ -15,6 +15,8 @@ jest.mock('@/api/client', () => ({ import * as envApi from '@/api/environments'; +const dialogStub = { template: '
' }; + const mockProject: ProjectResponse = { id: 10, name: 'Website', @@ -33,7 +35,10 @@ function mountComponent() { routes: [{ path: '/', component: { template: '
' } }], }); return mount(EnvironmentTabBar, { - global: { plugins: [router] }, + global: { + plugins: [router], + stubs: { 'v-dialog': dialogStub }, + }, }); } @@ -57,6 +62,13 @@ describe('EnvironmentTabBar', () => { expect(wrapper.find('[data-testid="env-no-project-message"]').exists()).toBe(true); }); + + it('ThenCreateEnvironmentButtonIsDisabled', async () => { + const wrapper = mountComponent(); + await flushPromises(); + + expect(wrapper.find('[data-testid="create-env-btn"]').attributes('disabled')).toBeDefined(); + }); }); describe('WhenProjectIsSetInContextStore', () => { @@ -96,6 +108,18 @@ describe('EnvironmentTabBar', () => { expect(contextStore.currentEnvironment).toEqual(mockEnvironments[0]); }); + + it('ThenCreateEnvironmentButtonIsEnabled', async () => { + jest.mocked(envApi.listEnvironments).mockResolvedValue(mockEnvironments); + + const contextStore = useContextStore(); + contextStore.setProject(mockProject); + + const wrapper = mountComponent(); + await flushPromises(); + + expect(wrapper.find('[data-testid="create-env-btn"]').attributes('disabled')).toBeUndefined(); + }); }); describe('WhenEnvironmentChipIsClicked', () => { @@ -108,7 +132,6 @@ describe('EnvironmentTabBar', () => { contextStore.setProject(mockProject); await flushPromises(); - // Simulate chip-group selecting index 1 (Production) await wrapper.findComponent({ name: 'VChipGroup' }).vm.$emit('update:modelValue', 1); expect(contextStore.currentEnvironment).toEqual(mockEnvironments[1]); @@ -147,4 +170,149 @@ describe('EnvironmentTabBar', () => { expect(wrapper.find('[data-testid="env-empty-message"]').exists()).toBe(true); }); }); + + describe('WhenCreateEnvironmentButtonIsClicked', () => { + it('ThenCreateDialogIsShown', async () => { + jest.mocked(envApi.listEnvironments).mockResolvedValue([]); + + const contextStore = useContextStore(); + contextStore.setProject(mockProject); + + const wrapper = mountComponent(); + await flushPromises(); + + await wrapper.find('[data-testid="create-env-btn"]').trigger('click'); + + expect(wrapper.find('[data-testid="create-env-dialog"]').exists()).toBe(true); + }); + + it('ThenNameInputIsPresentInDialog', async () => { + jest.mocked(envApi.listEnvironments).mockResolvedValue([]); + + const contextStore = useContextStore(); + contextStore.setProject(mockProject); + + const wrapper = mountComponent(); + await flushPromises(); + + await wrapper.find('[data-testid="create-env-btn"]').trigger('click'); + + expect(wrapper.find('[data-testid="env-name-input"]').exists()).toBe(true); + }); + }); + + describe('WhenCreateEnvironmentIsConfirmed', () => { + const newEnv: EnvironmentResponse = { + id: 200, name: 'Staging', apiKey: 'env-key-staging', projectId: 10, createdAt: '2026-04-14T00:00:00Z', + }; + + it('ThenCreateEnvironmentApiIsCalled', async () => { + jest.mocked(envApi.listEnvironments).mockResolvedValue([]); + jest.mocked(envApi.createEnvironment).mockResolvedValue(newEnv); + + const contextStore = useContextStore(); + contextStore.setProject(mockProject); + + const wrapper = mountComponent(); + await flushPromises(); + + await wrapper.find('[data-testid="create-env-btn"]').trigger('click'); + (wrapper.vm as any).newEnvName = 'Staging'; + await (wrapper.vm as any).onCreateConfirm(); + await flushPromises(); + + expect(envApi.createEnvironment).toHaveBeenCalledWith({ name: 'Staging', projectId: mockProject.id }); + }); + + it('ThenNewEnvironmentIsAutoSelectedInContextStore', async () => { + jest.mocked(envApi.listEnvironments).mockResolvedValue([]); + jest.mocked(envApi.createEnvironment).mockResolvedValue(newEnv); + + const contextStore = useContextStore(); + contextStore.setProject(mockProject); + + const wrapper = mountComponent(); + await flushPromises(); + + await wrapper.find('[data-testid="create-env-btn"]').trigger('click'); + (wrapper.vm as any).newEnvName = 'Staging'; + await (wrapper.vm as any).onCreateConfirm(); + await flushPromises(); + + expect(contextStore.currentEnvironment).toEqual(newEnv); + }); + + it('ThenDialogIsClosedAfterSuccessfulCreate', async () => { + jest.mocked(envApi.listEnvironments).mockResolvedValue([]); + jest.mocked(envApi.createEnvironment).mockResolvedValue(newEnv); + + const contextStore = useContextStore(); + contextStore.setProject(mockProject); + + const wrapper = mountComponent(); + await flushPromises(); + + await wrapper.find('[data-testid="create-env-btn"]').trigger('click'); + (wrapper.vm as any).newEnvName = 'Staging'; + await (wrapper.vm as any).onCreateConfirm(); + await flushPromises(); + + expect((wrapper.vm as any).showDialog).toBe(false); + }); + }); + + describe('WhenCreateEnvironmentFails', () => { + it('ThenErrorMessageIsDisplayed', async () => { + jest.mocked(envApi.listEnvironments).mockResolvedValue([]); + jest.mocked(envApi.createEnvironment).mockRejectedValue(new Error('Server error')); + + const contextStore = useContextStore(); + contextStore.setProject(mockProject); + + const wrapper = mountComponent(); + await flushPromises(); + + await wrapper.find('[data-testid="create-env-btn"]').trigger('click'); + (wrapper.vm as any).newEnvName = 'Bad Env'; + await (wrapper.vm as any).onCreateConfirm(); + await flushPromises(); + + expect((wrapper.vm as any).createError).toBeTruthy(); + }); + + it('ThenDialogRemainsOpen', async () => { + jest.mocked(envApi.listEnvironments).mockResolvedValue([]); + jest.mocked(envApi.createEnvironment).mockRejectedValue(new Error('Server error')); + + const contextStore = useContextStore(); + contextStore.setProject(mockProject); + + const wrapper = mountComponent(); + await flushPromises(); + + await wrapper.find('[data-testid="create-env-btn"]').trigger('click'); + (wrapper.vm as any).newEnvName = 'Bad Env'; + await (wrapper.vm as any).onCreateConfirm(); + await flushPromises(); + + expect((wrapper.vm as any).showDialog).toBe(true); + }); + }); + + describe('WhenCancelIsClicked', () => { + it('ThenDialogIsClosed', async () => { + jest.mocked(envApi.listEnvironments).mockResolvedValue([]); + + const contextStore = useContextStore(); + contextStore.setProject(mockProject); + + const wrapper = mountComponent(); + await flushPromises(); + + await wrapper.find('[data-testid="create-env-btn"]').trigger('click'); + await wrapper.find('[data-testid="create-env-cancel-btn"]').trigger('click'); + + expect((wrapper.vm as any).showDialog).toBe(false); + }); + }); }); diff --git a/src/admin/tests/unit/components/OrgSelector.spec.ts b/src/admin/tests/unit/components/OrgSelector.spec.ts index 343e8cd..32fe763 100644 --- a/src/admin/tests/unit/components/OrgSelector.spec.ts +++ b/src/admin/tests/unit/components/OrgSelector.spec.ts @@ -15,6 +15,8 @@ jest.mock('@/api/client', () => ({ import * as orgsApi from '@/api/organizations'; +const dialogStub = { template: '
' }; + const mockOrgs: OrganizationResponse[] = [ { id: 1, name: 'Acme Corp', createdAt: '2026-01-01T00:00:00Z' }, { id: 2, name: 'Globex', createdAt: '2026-01-01T00:00:00Z' }, @@ -26,7 +28,10 @@ function mountComponent() { routes: [{ path: '/', component: { template: '
' } }], }); return mount(OrgSelector, { - global: { plugins: [router] }, + global: { + plugins: [router], + stubs: { 'v-dialog': dialogStub }, + }, }); } @@ -55,6 +60,15 @@ describe('OrgSelector', () => { const select = wrapper.find('[data-testid="org-select"]'); expect(select.exists()).toBe(true); }); + + it('ThenCreateOrganizationButtonIsAlwaysVisible', async () => { + jest.mocked(orgsApi.listOrganizations).mockResolvedValue(mockOrgs); + + const wrapper = mountComponent(); + await flushPromises(); + + expect(wrapper.find('[data-testid="create-org-btn"]').exists()).toBe(true); + }); }); describe('WhenOrganizationIsSelected', () => { @@ -65,7 +79,6 @@ describe('OrgSelector', () => { await flushPromises(); const contextStore = useContextStore(); - // Simulate the v-select emitting an update await wrapper.findComponent({ name: 'VSelect' }).vm.$emit('update:modelValue', mockOrgs[0]); expect(contextStore.currentOrganization).toEqual(mockOrgs[0]); @@ -73,14 +86,13 @@ describe('OrgSelector', () => { }); describe('WhenApiReturnsNoOrganizations', () => { - it('ThenEmptyMessageIsDisplayed', async () => { + it('ThenCreateButtonIsStillVisible', async () => { jest.mocked(orgsApi.listOrganizations).mockResolvedValue([]); const wrapper = mountComponent(); await flushPromises(); - const emptyMsg = wrapper.find('[data-testid="org-empty-message"]'); - expect(emptyMsg.exists()).toBe(true); + expect(wrapper.find('[data-testid="create-org-btn"]').exists()).toBe(true); }); }); @@ -91,8 +103,129 @@ describe('OrgSelector', () => { const wrapper = mountComponent(); await flushPromises(); - // No crash — component stays rendered expect(wrapper.find('[data-testid="org-selector"]').exists()).toBe(true); }); }); + + describe('WhenCreateOrganizationButtonIsClicked', () => { + it('ThenCreateDialogIsShown', async () => { + jest.mocked(orgsApi.listOrganizations).mockResolvedValue([]); + + const wrapper = mountComponent(); + await flushPromises(); + + await wrapper.find('[data-testid="create-org-btn"]').trigger('click'); + + expect(wrapper.find('[data-testid="create-org-dialog"]').exists()).toBe(true); + }); + + it('ThenNameInputIsPresentInDialog', async () => { + jest.mocked(orgsApi.listOrganizations).mockResolvedValue([]); + + const wrapper = mountComponent(); + await flushPromises(); + + await wrapper.find('[data-testid="create-org-btn"]').trigger('click'); + + expect(wrapper.find('[data-testid="org-name-input"]').exists()).toBe(true); + }); + }); + + describe('WhenCreateOrganizationIsConfirmed', () => { + const newOrg: OrganizationResponse = { id: 3, name: 'New Org', createdAt: '2026-04-13T00:00:00Z' }; + + it('ThenCreateOrganizationApiIsCalled', async () => { + jest.mocked(orgsApi.listOrganizations).mockResolvedValue([]); + jest.mocked(orgsApi.createOrganization).mockResolvedValue(newOrg); + + const wrapper = mountComponent(); + await flushPromises(); + + await wrapper.find('[data-testid="create-org-btn"]').trigger('click'); + (wrapper.vm as any).newOrgName = 'New Org'; + await (wrapper.vm as any).onCreateConfirm(); + await flushPromises(); + + expect(orgsApi.createOrganization).toHaveBeenCalledWith({ name: 'New Org' }); + }); + + it('ThenNewOrgIsAutoSelectedInContextStore', async () => { + jest.mocked(orgsApi.listOrganizations).mockResolvedValue([]); + jest.mocked(orgsApi.createOrganization).mockResolvedValue(newOrg); + + const wrapper = mountComponent(); + await flushPromises(); + + const contextStore = useContextStore(); + + await wrapper.find('[data-testid="create-org-btn"]').trigger('click'); + (wrapper.vm as any).newOrgName = 'New Org'; + await (wrapper.vm as any).onCreateConfirm(); + await flushPromises(); + + expect(contextStore.currentOrganization).toEqual(newOrg); + }); + + it('ThenDialogIsClosedAfterSuccessfulCreate', async () => { + jest.mocked(orgsApi.listOrganizations).mockResolvedValue([]); + jest.mocked(orgsApi.createOrganization).mockResolvedValue(newOrg); + + const wrapper = mountComponent(); + await flushPromises(); + + await wrapper.find('[data-testid="create-org-btn"]').trigger('click'); + (wrapper.vm as any).newOrgName = 'New Org'; + await (wrapper.vm as any).onCreateConfirm(); + await flushPromises(); + + expect((wrapper.vm as any).showDialog).toBe(false); + }); + }); + + describe('WhenCreateOrganizationFails', () => { + it('ThenErrorMessageIsDisplayed', async () => { + jest.mocked(orgsApi.listOrganizations).mockResolvedValue([]); + jest.mocked(orgsApi.createOrganization).mockRejectedValue(new Error('Server error')); + + const wrapper = mountComponent(); + await flushPromises(); + + await wrapper.find('[data-testid="create-org-btn"]').trigger('click'); + (wrapper.vm as any).newOrgName = 'Bad Org'; + await (wrapper.vm as any).onCreateConfirm(); + await flushPromises(); + + expect((wrapper.vm as any).createError).toBeTruthy(); + }); + + it('ThenDialogRemainsOpen', async () => { + jest.mocked(orgsApi.listOrganizations).mockResolvedValue([]); + jest.mocked(orgsApi.createOrganization).mockRejectedValue(new Error('Server error')); + + const wrapper = mountComponent(); + await flushPromises(); + + await wrapper.find('[data-testid="create-org-btn"]').trigger('click'); + (wrapper.vm as any).newOrgName = 'Bad Org'; + await (wrapper.vm as any).onCreateConfirm(); + await flushPromises(); + + expect((wrapper.vm as any).showDialog).toBe(true); + }); + }); + + describe('WhenCancelIsClicked', () => { + it('ThenDialogIsClosed', async () => { + jest.mocked(orgsApi.listOrganizations).mockResolvedValue([]); + + const wrapper = mountComponent(); + await flushPromises(); + + await wrapper.find('[data-testid="create-org-btn"]').trigger('click'); + await wrapper.find('[data-testid="create-org-cancel-btn"]').trigger('click'); + + const vm = wrapper.vm as any; + expect(vm.showDialog).toBe(false); + }); + }); }); diff --git a/src/admin/tests/unit/components/ProjectSelector.spec.ts b/src/admin/tests/unit/components/ProjectSelector.spec.ts index 4844978..06d8ce5 100644 --- a/src/admin/tests/unit/components/ProjectSelector.spec.ts +++ b/src/admin/tests/unit/components/ProjectSelector.spec.ts @@ -15,6 +15,8 @@ jest.mock('@/api/client', () => ({ import * as projectsApi from '@/api/projects'; +const dialogStub = { template: '
' }; + const mockOrg: OrganizationResponse = { id: 1, name: 'Acme', createdAt: '2026-01-01T00:00:00Z' }; const mockProjects: ProjectResponse[] = [ { id: 10, name: 'Website', organizationId: 1, hideDisabledFlags: false, createdAt: '2026-01-01T00:00:00Z' }, @@ -27,7 +29,10 @@ function mountComponent() { routes: [{ path: '/', component: { template: '
' } }], }); return mount(ProjectSelector, { - global: { plugins: [router] }, + global: { + plugins: [router], + stubs: { 'v-dialog': dialogStub }, + }, }); } @@ -45,6 +50,15 @@ describe('ProjectSelector', () => { expect(projectsApi.listProjects).not.toHaveBeenCalled(); expect(wrapper.find('[data-testid="project-selector"]').exists()).toBe(true); }); + + it('ThenCreateProjectButtonIsDisabled', async () => { + const wrapper = mountComponent(); + await flushPromises(); + + const btn = wrapper.find('[data-testid="create-project-btn"]'); + expect(btn.exists()).toBe(true); + expect(btn.attributes('disabled')).toBeDefined(); + }); }); describe('WhenOrganizationIsSetInContextStore', () => { @@ -59,6 +73,19 @@ describe('ProjectSelector', () => { expect(projectsApi.listProjects).toHaveBeenCalledWith(mockOrg.id); }); + + it('ThenCreateProjectButtonIsEnabled', async () => { + jest.mocked(projectsApi.listProjects).mockResolvedValue(mockProjects); + + const contextStore = useContextStore(); + contextStore.setOrganization(mockOrg); + + const wrapper = mountComponent(); + await flushPromises(); + + const btn = wrapper.find('[data-testid="create-project-btn"]'); + expect(btn.attributes('disabled')).toBeUndefined(); + }); }); describe('WhenProjectIsSelected', () => { @@ -95,4 +122,149 @@ describe('ProjectSelector', () => { expect(projectsApi.listProjects).toHaveBeenLastCalledWith(newOrg.id); }); }); + + describe('WhenCreateProjectButtonIsClicked', () => { + it('ThenCreateDialogIsShown', async () => { + jest.mocked(projectsApi.listProjects).mockResolvedValue([]); + + const contextStore = useContextStore(); + contextStore.setOrganization(mockOrg); + + const wrapper = mountComponent(); + await flushPromises(); + + await wrapper.find('[data-testid="create-project-btn"]').trigger('click'); + + expect(wrapper.find('[data-testid="create-project-dialog"]').exists()).toBe(true); + }); + + it('ThenNameInputIsPresentInDialog', async () => { + jest.mocked(projectsApi.listProjects).mockResolvedValue([]); + + const contextStore = useContextStore(); + contextStore.setOrganization(mockOrg); + + const wrapper = mountComponent(); + await flushPromises(); + + await wrapper.find('[data-testid="create-project-btn"]').trigger('click'); + + expect(wrapper.find('[data-testid="project-name-input"]').exists()).toBe(true); + }); + }); + + describe('WhenCreateProjectIsConfirmed', () => { + const newProject: ProjectResponse = { + id: 99, name: 'New Project', organizationId: 1, hideDisabledFlags: false, createdAt: '2026-04-13T00:00:00Z', + }; + + it('ThenCreateProjectApiIsCalled', async () => { + jest.mocked(projectsApi.listProjects).mockResolvedValue([]); + jest.mocked(projectsApi.createProject).mockResolvedValue(newProject); + + const contextStore = useContextStore(); + contextStore.setOrganization(mockOrg); + + const wrapper = mountComponent(); + await flushPromises(); + + await wrapper.find('[data-testid="create-project-btn"]').trigger('click'); + (wrapper.vm as any).newProjectName = 'New Project'; + await (wrapper.vm as any).onCreateConfirm(); + await flushPromises(); + + expect(projectsApi.createProject).toHaveBeenCalledWith({ name: 'New Project', organizationId: mockOrg.id }); + }); + + it('ThenNewProjectIsAutoSelectedInContextStore', async () => { + jest.mocked(projectsApi.listProjects).mockResolvedValue([]); + jest.mocked(projectsApi.createProject).mockResolvedValue(newProject); + + const contextStore = useContextStore(); + contextStore.setOrganization(mockOrg); + + const wrapper = mountComponent(); + await flushPromises(); + + await wrapper.find('[data-testid="create-project-btn"]').trigger('click'); + (wrapper.vm as any).newProjectName = 'New Project'; + await (wrapper.vm as any).onCreateConfirm(); + await flushPromises(); + + expect(contextStore.currentProject).toEqual(newProject); + }); + + it('ThenDialogIsClosedAfterSuccessfulCreate', async () => { + jest.mocked(projectsApi.listProjects).mockResolvedValue([]); + jest.mocked(projectsApi.createProject).mockResolvedValue(newProject); + + const contextStore = useContextStore(); + contextStore.setOrganization(mockOrg); + + const wrapper = mountComponent(); + await flushPromises(); + + await wrapper.find('[data-testid="create-project-btn"]').trigger('click'); + (wrapper.vm as any).newProjectName = 'New Project'; + await (wrapper.vm as any).onCreateConfirm(); + await flushPromises(); + + expect((wrapper.vm as any).showDialog).toBe(false); + }); + }); + + describe('WhenCreateProjectFails', () => { + it('ThenErrorMessageIsDisplayed', async () => { + jest.mocked(projectsApi.listProjects).mockResolvedValue([]); + jest.mocked(projectsApi.createProject).mockRejectedValue(new Error('Server error')); + + const contextStore = useContextStore(); + contextStore.setOrganization(mockOrg); + + const wrapper = mountComponent(); + await flushPromises(); + + await wrapper.find('[data-testid="create-project-btn"]').trigger('click'); + (wrapper.vm as any).newProjectName = 'Bad Project'; + await (wrapper.vm as any).onCreateConfirm(); + await flushPromises(); + + expect((wrapper.vm as any).createError).toBeTruthy(); + }); + + it('ThenDialogRemainsOpen', async () => { + jest.mocked(projectsApi.listProjects).mockResolvedValue([]); + jest.mocked(projectsApi.createProject).mockRejectedValue(new Error('Server error')); + + const contextStore = useContextStore(); + contextStore.setOrganization(mockOrg); + + const wrapper = mountComponent(); + await flushPromises(); + + await wrapper.find('[data-testid="create-project-btn"]').trigger('click'); + (wrapper.vm as any).newProjectName = 'Bad Project'; + await (wrapper.vm as any).onCreateConfirm(); + await flushPromises(); + + expect((wrapper.vm as any).showDialog).toBe(true); + }); + }); + + describe('WhenCancelIsClicked', () => { + it('ThenDialogIsClosed', async () => { + jest.mocked(projectsApi.listProjects).mockResolvedValue([]); + + const contextStore = useContextStore(); + contextStore.setOrganization(mockOrg); + + const wrapper = mountComponent(); + await flushPromises(); + + await wrapper.find('[data-testid="create-project-btn"]').trigger('click'); + await wrapper.find('[data-testid="create-project-cancel-btn"]').trigger('click'); + + expect((wrapper.vm as any).showDialog).toBe(false); + }); + }); }); diff --git a/src/admin/tests/unit/views/ProfileView.spec.ts b/src/admin/tests/unit/views/ProfileView.spec.ts new file mode 100644 index 0000000..60a722a --- /dev/null +++ b/src/admin/tests/unit/views/ProfileView.spec.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, beforeEach, jest } from '@jest/globals'; +import { mount, flushPromises } from '@vue/test-utils'; +import { setActivePinia, createPinia } from 'pinia'; +import { createRouter, createMemoryHistory } from 'vue-router'; +import ProfileView from '@/views/ProfileView.vue'; +import { useAuthStore } from '@/stores/auth'; +import { useContextStore } from '@/stores/context'; + +jest.mock('@/api/auth'); +jest.mock('@/api/client', () => ({ + setAuthTokens: jest.fn(), + clearAuthTokens: jest.fn(), + getAccessToken: jest.fn(), +})); + +import * as authApi from '@/api/auth'; + +function mountView() { + const routes = [ + { path: '/', component: { template: '
' } }, + { path: '/login', name: 'Login', component: { template: '
' } }, + ]; + const router = createRouter({ history: createMemoryHistory(), routes }); + return { wrapper: mount(ProfileView, { global: { plugins: [router] } }), router }; +} + +describe('ProfileView', () => { + beforeEach(() => { + setActivePinia(createPinia()); + jest.clearAllMocks(); + }); + + describe('WhenAuthenticated', () => { + it('ThenProfileCardIsRendered', () => { + const { wrapper } = mountView(); + expect(wrapper.find('[data-testid="profile-card"]').exists()).toBe(true); + }); + + it('ThenLogoutButtonIsPresent', () => { + const { wrapper } = mountView(); + expect(wrapper.find('[data-testid="logout-btn"]').exists()).toBe(true); + }); + + it('ThenOrgNameIsDisplayedWhenSelected', () => { + const { wrapper } = mountView(); + const contextStore = useContextStore(); + contextStore.setOrganization({ id: 1, name: 'Acme Corp', createdAt: '' }); + + expect(wrapper.find('[data-testid="profile-org-name"]').exists()).toBe(false); // org name shown in v-if block + }); + }); + + describe('WhenLogoutButtonIsClicked', () => { + it('ThenAuthStoreLogoutIsCalled', async () => { + jest.mocked(authApi.logout).mockResolvedValue(undefined); + + const authStore = useAuthStore(); + // Simulate logged-in state by setting tokens manually + authStore.accessToken = 'fake-token' as any; + authStore.refreshToken = 'fake-refresh' as any; + + const { wrapper } = mountView(); + + await wrapper.find('[data-testid="logout-btn"]').trigger('click'); + await flushPromises(); + + expect(authApi.logout).toHaveBeenCalled(); + }); + + it('ThenUserIsRedirectedToLoginAfterLogout', async () => { + jest.mocked(authApi.logout).mockResolvedValue(undefined); + + const authStore = useAuthStore(); + authStore.accessToken = 'fake-token' as any; + authStore.refreshToken = 'fake-refresh' as any; + + const { wrapper, router } = mountView(); + + await wrapper.find('[data-testid="logout-btn"]').trigger('click'); + await flushPromises(); + + expect(router.currentRoute.value.name).toBe('Login'); + }); + }); +}); diff --git a/src/admin/tests/unit/views/SettingsView.spec.ts b/src/admin/tests/unit/views/SettingsView.spec.ts new file mode 100644 index 0000000..fcbb38e --- /dev/null +++ b/src/admin/tests/unit/views/SettingsView.spec.ts @@ -0,0 +1,480 @@ +import { describe, it, expect, beforeEach, jest } from '@jest/globals'; +import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'; +import { setActivePinia, createPinia } from 'pinia'; +import { createRouter, createMemoryHistory } from 'vue-router'; +import SettingsView from '@/views/SettingsView.vue'; +import { useContextStore } from '@/stores/context'; +import type { + ProjectResponse, OrganizationResponse, EnvironmentResponse, + UserPermissionResponse, ApiKeyResponse, CreateApiKeyResponse, +} from '@/types/api'; + +jest.mock('@/api/organizations'); +jest.mock('@/api/projects'); +jest.mock('@/api/environments'); +jest.mock('@/api/apiKeys'); +jest.mock('@/api/webhooks'); +jest.mock('@/api/client', () => ({ + setAuthTokens: jest.fn(), + clearAuthTokens: jest.fn(), + getAccessToken: jest.fn(), +})); + +import * as orgsApi from '@/api/organizations'; +import * as projectsApi from '@/api/projects'; +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 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' }, + { id: 200, name: 'Production', apiKey: 'env-prod', projectId: 10, createdAt: '2026-01-02T00:00:00Z' }, +]; +const mockApiKeys: ApiKeyResponse[] = [ + { id: 1, name: 'CI Key', prefix: 'org-ci', isActive: true, expiresAt: null, createdAt: '2026-01-01T00:00:00Z' }, +]; +const mockPermissions: UserPermissionResponse[] = [ + { userId: 5, projectId: 10, isAdmin: false, permissions: ['ViewProject', 'EditFeature'] }, +]; + +const dialogStub = { template: '
' }; + +function mountView() { + const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '
' } }] }); + return mount(SettingsView, { + global: { plugins: [router], stubs: { 'v-dialog': dialogStub } }, + }); +} + +describe('SettingsView', () => { + beforeEach(() => { + setActivePinia(createPinia()); + jest.clearAllMocks(); + + jest.mocked(orgsApi.listOrganizationMembers).mockResolvedValue([]); + jest.mocked(projectsApi.listProjectUserPermissions).mockResolvedValue([]); + jest.mocked(environmentsApi.listEnvironments).mockResolvedValue([]); + jest.mocked(apiKeysApi.listApiKeys).mockResolvedValue([]); + jest.mocked(webhooksApi.listOrgWebhooks).mockResolvedValue([]); + jest.mocked(webhooksApi.listEnvWebhooks).mockResolvedValue([]); + }); + + describe('WhenNoOrganizationSelected', () => { + it('ThenSelectOrgMessageIsShown', () => { + const wrapper = mountView(); + expect(wrapper.text()).toContain('Select an organization'); + }); + }); + + describe('WhenOrganizationIsSelected', () => { + it('ThenTabsAreRendered', async () => { + const contextStore = useContextStore(); + contextStore.setOrganization(mockOrg); + + const wrapper = mountView(); + await flushPromises(); + + expect(wrapper.find('[data-testid="tab-organization"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="tab-api-keys"]').exists()).toBe(true); + }); + }); + + // ── Organization tab ──────────────────────────────────────────────────────── + + describe('WhenSaveOrgNameIsClicked', () => { + it('ThenUpdateOrganizationIsCalledWithNewName', async () => { + jest.mocked(orgsApi.updateOrganization).mockResolvedValue({ ...mockOrg, name: 'New Name' }); + + const contextStore = useContextStore(); + contextStore.setOrganization(mockOrg); + + const wrapper = mountView(); + await flushPromises(); + + await (wrapper.findComponent('[data-testid="org-name-input"]') as VueWrapper).vm.$emit('update:modelValue', 'New Name'); + await wrapper.find('[data-testid="save-org-name-btn"]').trigger('click'); + await flushPromises(); + + expect(orgsApi.updateOrganization).toHaveBeenCalledWith(mockOrg.id, { name: 'New Name' }); + }); + }); + + describe('WhenInviteMemberIsClicked', () => { + it('ThenInviteOrganizationMemberIsCalledWithUserId', async () => { + jest.mocked(orgsApi.inviteOrganizationMember).mockResolvedValue(undefined); + jest.mocked(orgsApi.listOrganizationMembers).mockResolvedValue([{ userId: 42, role: 'User' }]); + + const contextStore = useContextStore(); + contextStore.setOrganization(mockOrg); + + const wrapper = mountView(); + await flushPromises(); + + await (wrapper.findComponent('[data-testid="invite-user-id-input"]') as VueWrapper).vm.$emit('update:modelValue', '42'); + await wrapper.vm.$nextTick(); + + await wrapper.find('[data-testid="invite-member-btn"]').trigger('click'); + await flushPromises(); + + expect(orgsApi.inviteOrganizationMember).toHaveBeenCalledWith(mockOrg.id, { userId: 42, role: 'User' }); + }); + }); + + describe('WhenRemoveMemberIsClicked', () => { + it('ThenRemoveOrganizationMemberIsCalledAndMemberIsRemovedFromList', async () => { + jest.mocked(orgsApi.listOrganizationMembers).mockResolvedValue([{ userId: 5, role: 'User' }]); + jest.mocked(orgsApi.removeOrganizationMember).mockResolvedValue(undefined); + + const contextStore = useContextStore(); + contextStore.setOrganization(mockOrg); + + const wrapper = mountView(); + await flushPromises(); + + expect(wrapper.find('[data-testid="member-row-5"]').exists()).toBe(true); + + await wrapper.find('[data-testid="remove-member-5"]').trigger('click'); + await flushPromises(); + + expect(orgsApi.removeOrganizationMember).toHaveBeenCalledWith(mockOrg.id, 5); + expect(wrapper.find('[data-testid="member-row-5"]').exists()).toBe(false); + }); + }); + + // ── Project tab ───────────────────────────────────────────────────────────── + + describe('WhenSaveProjectIsClicked', () => { + it('ThenUpdateProjectIsCalledWithFormValues', async () => { + jest.mocked(projectsApi.updateProject).mockResolvedValue(mockProject); + + const contextStore = useContextStore(); + contextStore.setOrganization(mockOrg); + contextStore.setProject(mockProject); + + const wrapper = mountView(); + await flushPromises(); + + await wrapper.find('[data-testid="tab-project"]').trigger('click'); + await wrapper.vm.$nextTick(); + + await wrapper.find('[data-testid="save-project-btn"]').trigger('click'); + await flushPromises(); + + expect(projectsApi.updateProject).toHaveBeenCalledWith( + mockProject.id, + expect.objectContaining({ name: mockProject.name }), + ); + }); + }); + + describe('WhenPermissionsAreLoaded', () => { + it('ThenPermissionRowsAreDisplayed', async () => { + jest.mocked(projectsApi.listProjectUserPermissions).mockResolvedValue(mockPermissions); + + const contextStore = useContextStore(); + contextStore.setOrganization(mockOrg); + contextStore.setProject(mockProject); + + const wrapper = mountView(); + await flushPromises(); + + await wrapper.find('[data-testid="tab-project"]').trigger('click'); + await wrapper.vm.$nextTick(); + + expect(wrapper.find('[data-testid="permission-row-5"]').exists()).toBe(true); + }); + }); + + describe('WhenAdminToggleIsChanged', () => { + it('ThenUpdateProjectUserPermissionsIsCalledWithIsAdmin', async () => { + jest.mocked(projectsApi.listProjectUserPermissions).mockResolvedValue(mockPermissions); + jest.mocked(projectsApi.updateProjectUserPermissions).mockResolvedValue({ + ...mockPermissions[0], isAdmin: true, + }); + + const contextStore = useContextStore(); + contextStore.setOrganization(mockOrg); + contextStore.setProject(mockProject); + + const wrapper = mountView(); + await flushPromises(); + + await wrapper.find('[data-testid="tab-project"]').trigger('click'); + await wrapper.vm.$nextTick(); + + await (wrapper.findComponent(`[data-testid="perm-admin-toggle-5"]`) as VueWrapper).vm.$emit('update:modelValue', true); + await flushPromises(); + + expect(projectsApi.updateProjectUserPermissions).toHaveBeenCalledWith( + mockProject.id, 5, + expect.objectContaining({ isAdmin: true }), + ); + }); + }); + + describe('WhenAddUserPermissionsIsSubmitted', () => { + it('ThenSetProjectUserPermissionsIsCalledWithNewUserId', async () => { + const newPerm: UserPermissionResponse = { userId: 99, projectId: 10, isAdmin: false, permissions: ['ViewProject'] }; + jest.mocked(projectsApi.setProjectUserPermissions).mockResolvedValue(newPerm); + + const contextStore = useContextStore(); + contextStore.setOrganization(mockOrg); + contextStore.setProject(mockProject); + + const wrapper = mountView(); + await flushPromises(); + + await wrapper.find('[data-testid="tab-project"]').trigger('click'); + await wrapper.vm.$nextTick(); + + await wrapper.find('[data-testid="add-permission-btn"]').trigger('click'); + await wrapper.vm.$nextTick(); + + await (wrapper.findComponent('[data-testid="new-perm-user-id-input"]') as VueWrapper).vm.$emit('update:modelValue', '99'); + await wrapper.vm.$nextTick(); + + await wrapper.find('[data-testid="save-new-permission-btn"]').trigger('click'); + await flushPromises(); + + expect(projectsApi.setProjectUserPermissions).toHaveBeenCalledWith( + mockProject.id, + expect.objectContaining({ userId: 99 }), + ); + }); + }); + + describe('WhenRemovePermissionIsClicked', () => { + it('ThenRemoveProjectUserPermissionsIsCalledAndRowIsRemoved', async () => { + jest.mocked(projectsApi.listProjectUserPermissions).mockResolvedValue(mockPermissions); + jest.mocked(projectsApi.removeProjectUserPermissions).mockResolvedValue(undefined); + + const contextStore = useContextStore(); + contextStore.setOrganization(mockOrg); + contextStore.setProject(mockProject); + + const wrapper = mountView(); + await flushPromises(); + + await wrapper.find('[data-testid="tab-project"]').trigger('click'); + await wrapper.vm.$nextTick(); + + await wrapper.find('[data-testid="remove-permission-5"]').trigger('click'); + await flushPromises(); + + expect(projectsApi.removeProjectUserPermissions).toHaveBeenCalledWith(mockProject.id, 5); + expect(wrapper.find('[data-testid="permission-row-5"]').exists()).toBe(false); + }); + }); + + // ── Environments tab ──────────────────────────────────────────────────────── + + describe('WhenCreateEnvironmentIsClicked', () => { + it('ThenCreateEnvironmentIsCalledAndEnvIsAddedToList', async () => { + jest.mocked(environmentsApi.listEnvironments).mockResolvedValue([]); + jest.mocked(environmentsApi.createEnvironment).mockResolvedValue(mockEnvs[0]); + + const contextStore = useContextStore(); + contextStore.setOrganization(mockOrg); + contextStore.setProject(mockProject); + + const wrapper = mountView(); + await flushPromises(); + + await wrapper.find('[data-testid="tab-environments"]').trigger('click'); + await wrapper.vm.$nextTick(); + + await (wrapper.findComponent('[data-testid="new-env-name-input"]') as VueWrapper).vm.$emit('update:modelValue', 'Staging'); + await wrapper.vm.$nextTick(); + + await wrapper.find('[data-testid="create-env-btn"]').trigger('click'); + await flushPromises(); + + expect(environmentsApi.createEnvironment).toHaveBeenCalledWith({ projectId: mockProject.id, name: 'Staging' }); + }); + }); + + describe('WhenCloneEnvironmentIsConfirmed', () => { + it('ThenCloneEnvironmentIsCalledWithNewName', async () => { + jest.mocked(environmentsApi.listEnvironments).mockResolvedValue([...mockEnvs]); + jest.mocked(environmentsApi.cloneEnvironment).mockResolvedValue({ + ...mockEnvs[0], id: 300, name: 'Staging', apiKey: 'env-stg', + }); + + const contextStore = useContextStore(); + contextStore.setOrganization(mockOrg); + contextStore.setProject(mockProject); + + const wrapper = mountView(); + await flushPromises(); + + await wrapper.find('[data-testid="tab-environments"]').trigger('click'); + await wrapper.vm.$nextTick(); + + await wrapper.find('[data-testid="clone-env-100"]').trigger('click'); + await wrapper.vm.$nextTick(); + + await (wrapper.findComponent('[data-testid="clone-env-name-input"]') as VueWrapper).vm.$emit('update:modelValue', 'Staging'); + await wrapper.vm.$nextTick(); + + await wrapper.find('[data-testid="confirm-clone-btn"]').trigger('click'); + await flushPromises(); + + expect(environmentsApi.cloneEnvironment).toHaveBeenCalledWith(mockEnvs[0].apiKey, { name: 'Staging' }); + }); + }); + + describe('WhenDeleteEnvironmentIsConfirmed', () => { + it('ThenDeleteEnvironmentIsCalledAndEnvIsRemovedFromList', async () => { + jest.mocked(environmentsApi.listEnvironments).mockResolvedValue([...mockEnvs]); + jest.mocked(environmentsApi.deleteEnvironment).mockResolvedValue(undefined); + + const contextStore = useContextStore(); + contextStore.setOrganization(mockOrg); + contextStore.setProject(mockProject); + + const wrapper = mountView(); + await flushPromises(); + + await wrapper.find('[data-testid="tab-environments"]').trigger('click'); + await wrapper.vm.$nextTick(); + + await wrapper.find('[data-testid="delete-env-100"]').trigger('click'); + await wrapper.vm.$nextTick(); + + await (wrapper.findComponent('[data-testid="delete-env-confirm-input"]') as VueWrapper).vm.$emit('update:modelValue', 'Development'); + await wrapper.vm.$nextTick(); + + await wrapper.find('[data-testid="confirm-delete-env-btn"]').trigger('click'); + await flushPromises(); + + expect(environmentsApi.deleteEnvironment).toHaveBeenCalledWith(mockEnvs[0].apiKey); + }); + }); + + describe('WhenRenameEnvironmentIsConfirmed', () => { + it('ThenUpdateEnvironmentIsCalledWithNewName', async () => { + jest.mocked(environmentsApi.listEnvironments).mockResolvedValue([...mockEnvs]); + jest.mocked(environmentsApi.updateEnvironment).mockResolvedValue({ ...mockEnvs[0], name: 'Dev Renamed' }); + + const contextStore = useContextStore(); + contextStore.setOrganization(mockOrg); + contextStore.setProject(mockProject); + + const wrapper = mountView(); + await flushPromises(); + + await wrapper.find('[data-testid="tab-environments"]').trigger('click'); + await wrapper.vm.$nextTick(); + + await wrapper.find('[data-testid="rename-env-100"]').trigger('click'); + await wrapper.vm.$nextTick(); + + await (wrapper.findComponent('[data-testid="rename-env-name-input"]') as VueWrapper).vm.$emit('update:modelValue', 'Dev Renamed'); + await wrapper.vm.$nextTick(); + + await wrapper.find('[data-testid="confirm-rename-btn"]').trigger('click'); + await flushPromises(); + + expect(environmentsApi.updateEnvironment).toHaveBeenCalledWith(mockEnvs[0].apiKey, { name: 'Dev Renamed' }); + }); + }); + + // ── API Keys tab ──────────────────────────────────────────────────────────── + + describe('WhenApiKeysTabIsOpened', () => { + it('ThenApiKeysAreLoaded', async () => { + jest.mocked(apiKeysApi.listApiKeys).mockResolvedValue(mockApiKeys); + + const contextStore = useContextStore(); + contextStore.setOrganization(mockOrg); + + const wrapper = mountView(); + await flushPromises(); + + await wrapper.find('[data-testid="tab-api-keys"]').trigger('click'); + await wrapper.vm.$nextTick(); + + expect(wrapper.find('[data-testid="api-key-row-1"]').exists()).toBe(true); + expect(wrapper.text()).toContain('CI Key'); + }); + }); + + describe('WhenCreateApiKeyIsSubmitted', () => { + it('ThenCreateApiKeyIsCalledAndRawKeyDialogIsShown', async () => { + const created: CreateApiKeyResponse = { id: 99, name: 'My Key', rawKey: 'raw-secret-key', prefix: 'org-my', expiresAt: null }; + jest.mocked(apiKeysApi.createApiKey).mockResolvedValue(created); + + const contextStore = useContextStore(); + contextStore.setOrganization(mockOrg); + + const wrapper = mountView(); + await flushPromises(); + + await wrapper.find('[data-testid="tab-api-keys"]').trigger('click'); + await wrapper.vm.$nextTick(); + + await wrapper.find('[data-testid="create-api-key-btn"]').trigger('click'); + await wrapper.vm.$nextTick(); + + await (wrapper.findComponent('[data-testid="api-key-name-input"]') as VueWrapper).vm.$emit('update:modelValue', 'My Key'); + await wrapper.vm.$nextTick(); + + await wrapper.find('[data-testid="save-api-key-btn"]').trigger('click'); + await flushPromises(); + + expect(apiKeysApi.createApiKey).toHaveBeenCalledWith(mockOrg.id, expect.objectContaining({ name: 'My Key' })); + expect(wrapper.find('[data-testid="raw-key-dialog"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="raw-key-field"]').exists()).toBe(true); + }); + + it('ThenRawKeyIsDisplayedInTheRevealDialog', async () => { + const created: CreateApiKeyResponse = { id: 99, name: 'My Key', rawKey: 'raw-secret-key', prefix: 'org-my', expiresAt: null }; + jest.mocked(apiKeysApi.createApiKey).mockResolvedValue(created); + + const contextStore = useContextStore(); + contextStore.setOrganization(mockOrg); + + const wrapper = mountView(); + await flushPromises(); + + await wrapper.find('[data-testid="tab-api-keys"]').trigger('click'); + await wrapper.vm.$nextTick(); + + await wrapper.find('[data-testid="create-api-key-btn"]').trigger('click'); + await wrapper.vm.$nextTick(); + + await (wrapper.findComponent('[data-testid="api-key-name-input"]') as VueWrapper).vm.$emit('update:modelValue', 'My Key'); + await wrapper.vm.$nextTick(); + + await wrapper.find('[data-testid="save-api-key-btn"]').trigger('click'); + await flushPromises(); + + const rawKeyField = wrapper.findComponent('[data-testid="raw-key-field"]') as VueWrapper; + expect(rawKeyField.props('modelValue')).toBe('raw-secret-key'); + }); + }); + + describe('WhenDeleteApiKeyIsClicked', () => { + it('ThenDeleteApiKeyIsCalledAndKeyIsRemovedFromList', async () => { + jest.mocked(apiKeysApi.listApiKeys).mockResolvedValue([...mockApiKeys]); + jest.mocked(apiKeysApi.deleteApiKey).mockResolvedValue(undefined); + + const contextStore = useContextStore(); + contextStore.setOrganization(mockOrg); + + const wrapper = mountView(); + await flushPromises(); + + await wrapper.find('[data-testid="tab-api-keys"]').trigger('click'); + await wrapper.vm.$nextTick(); + + await wrapper.find('[data-testid="delete-api-key-1"]').trigger('click'); + await flushPromises(); + + expect(apiKeysApi.deleteApiKey).toHaveBeenCalledWith(mockOrg.id, 1); + expect(wrapper.find('[data-testid="api-key-row-1"]').exists()).toBe(false); + }); + }); +}); diff --git a/src/api/MicCheck.Api/Audit/AuditService.cs b/src/api/MicCheck.Api/Audit/AuditService.cs index 770e0a3..fd027c5 100644 --- a/src/api/MicCheck.Api/Audit/AuditService.cs +++ b/src/api/MicCheck.Api/Audit/AuditService.cs @@ -114,7 +114,7 @@ public class AuditService(MicCheckDbContext db, IHttpContextAccessor httpContext private int? ResolveActorUserId() { - var claim = httpContextAccessor.HttpContext?.User.FindFirst("UserId")?.Value; + var claim = httpContextAccessor.HttpContext?.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; return int.TryParse(claim, out var id) ? id : null; } } diff --git a/src/api/MicCheck.Api/Data/Configurations/FeatureConfiguration.cs b/src/api/MicCheck.Api/Data/Configurations/FeatureConfiguration.cs index 2e5e101..72975d3 100644 --- a/src/api/MicCheck.Api/Data/Configurations/FeatureConfiguration.cs +++ b/src/api/MicCheck.Api/Data/Configurations/FeatureConfiguration.cs @@ -22,8 +22,7 @@ public class FeatureConfiguration : IEntityTypeConfiguration .OnDelete(DeleteBehavior.Cascade); builder.HasMany(f => f.Tags) - .WithOne() - .HasForeignKey(t => t.ProjectId) - .OnDelete(DeleteBehavior.NoAction); + .WithMany() + .UsingEntity("FeatureTags"); } } diff --git a/src/api/MicCheck.Api/Data/Configurations/TagConfiguration.cs b/src/api/MicCheck.Api/Data/Configurations/TagConfiguration.cs index 17aeebf..f996185 100644 --- a/src/api/MicCheck.Api/Data/Configurations/TagConfiguration.cs +++ b/src/api/MicCheck.Api/Data/Configurations/TagConfiguration.cs @@ -1,4 +1,5 @@ using MicCheck.Api.Features; +using MicCheck.Api.Projects; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; @@ -11,5 +12,10 @@ public class TagConfiguration : IEntityTypeConfiguration builder.HasKey(t => t.Id); builder.Property(t => t.Label).HasMaxLength(200).IsRequired(); builder.Property(t => t.Color).HasMaxLength(20).IsRequired(); + + builder.HasOne() + .WithMany() + .HasForeignKey(t => t.ProjectId) + .OnDelete(DeleteBehavior.Cascade); } } diff --git a/src/api/MicCheck.Api/Migrations/20260414203426_FixTagRelationships.Designer.cs b/src/api/MicCheck.Api/Migrations/20260414203426_FixTagRelationships.Designer.cs new file mode 100644 index 0000000..013f6e4 --- /dev/null +++ b/src/api/MicCheck.Api/Migrations/20260414203426_FixTagRelationships.Designer.cs @@ -0,0 +1,926 @@ +// +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("20260414203426_FixTagRelationships")] + partial class FixTagRelationships + { + /// + 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("FeatureId") + .HasColumnType("integer"); + + b.Property("TagsId") + .HasColumnType("integer"); + + b.HasKey("FeatureId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("FeatureTags"); + }); + + modelBuilder.Entity("MicCheck.Api.ApiKeys.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationId") + .HasColumnType("integer"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ActorUserId") + .HasColumnType("integer"); + + b.Property("Changes") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnvironmentId") + .HasColumnType("integer"); + + b.Property("OrganizationId") + .HasColumnType("integer"); + + b.Property("ProjectId") + .HasColumnType("integer"); + + b.Property("ResourceId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("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("UserId") + .HasColumnType("integer"); + + b.Property("ProjectId") + .HasColumnType("integer"); + + b.Property("IsAdmin") + .HasColumnType("boolean"); + + b.Property("Permissions") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("UserId", "ProjectId"); + + b.ToTable("UserProjectPermissions"); + }); + + modelBuilder.Entity("MicCheck.Api.Environments.Environment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultEnabled") + .HasColumnType("boolean"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("InitialValue") + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("ProjectId") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId", "Name") + .IsUnique(); + + b.ToTable("Features"); + }); + + modelBuilder.Entity("MicCheck.Api.Features.FeatureSegment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("EnvironmentId") + .HasColumnType("integer"); + + b.Property("FeatureId") + .HasColumnType("integer"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("SegmentId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("FeatureSegments"); + }); + + modelBuilder.Entity("MicCheck.Api.Features.FeatureState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("EnvironmentId") + .HasColumnType("integer"); + + b.Property("FeatureId") + .HasColumnType("integer"); + + b.Property("FeatureSegmentId") + .HasColumnType("integer"); + + b.Property("IdentityId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Color") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ProjectId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("MicCheck.Api.Identities.Identity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EnvironmentId") + .HasColumnType("integer"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("IdentityId") + .HasColumnType("integer"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("ValueType") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("IdentityId", "Key") + .IsUnique(); + + b.ToTable("IdentityTraits"); + }); + + modelBuilder.Entity("MicCheck.Api.Organizations.Organization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.ToTable("Organizations"); + }); + + modelBuilder.Entity("MicCheck.Api.Organizations.OrganizationUser", b => + { + b.Property("OrganizationId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("OrganizationId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("OrganizationUsers"); + }); + + modelBuilder.Entity("MicCheck.Api.Projects.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HideDisabledFlags") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Projects"); + }); + + modelBuilder.Entity("MicCheck.Api.Segments.Segment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ProjectId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("Segments"); + }); + + modelBuilder.Entity("MicCheck.Api.Segments.SegmentCondition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Operator") + .HasColumnType("integer"); + + b.Property("Property") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RuleId") + .HasColumnType("integer"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ParentRuleId") + .HasColumnType("integer"); + + b.Property("SegmentId") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ParentRuleId"); + + b.HasIndex("SegmentId"); + + b.ToTable("SegmentRules"); + }); + + modelBuilder.Entity("MicCheck.Api.Users.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsTwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("MicCheck.Api.Webhooks.Webhook", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("EnvironmentId") + .HasColumnType("integer"); + + b.Property("OrganizationId") + .HasColumnType("integer"); + + b.Property("Scope") + .HasColumnType("integer"); + + b.Property("Secret") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AttemptNumber") + .HasColumnType("integer"); + + b.Property("AttemptedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("ErrorMessage") + .HasColumnType("text"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("ResponseBody") + .HasColumnType("text"); + + b.Property("ResponseStatusCode") + .HasColumnType("integer"); + + b.Property("Success") + .HasColumnType("boolean"); + + b.Property("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", null) + .WithMany("Organizations") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + 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 + } + } +} diff --git a/src/api/MicCheck.Api/Migrations/20260414203426_FixTagRelationships.cs b/src/api/MicCheck.Api/Migrations/20260414203426_FixTagRelationships.cs new file mode 100644 index 0000000..080ea6e --- /dev/null +++ b/src/api/MicCheck.Api/Migrations/20260414203426_FixTagRelationships.cs @@ -0,0 +1,73 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MicCheck.Api.Migrations +{ + /// + public partial class FixTagRelationships : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Tags_Features_ProjectId", + table: "Tags"); + + migrationBuilder.CreateTable( + name: "FeatureTags", + columns: table => new + { + FeatureId = table.Column(type: "integer", nullable: false), + TagsId = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_FeatureTags", x => new { x.FeatureId, x.TagsId }); + table.ForeignKey( + name: "FK_FeatureTags_Features_FeatureId", + column: x => x.FeatureId, + principalTable: "Features", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_FeatureTags_Tags_TagsId", + column: x => x.TagsId, + principalTable: "Tags", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_FeatureTags_TagsId", + table: "FeatureTags", + column: "TagsId"); + + migrationBuilder.AddForeignKey( + name: "FK_Tags_Projects_ProjectId", + table: "Tags", + column: "ProjectId", + principalTable: "Projects", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Tags_Projects_ProjectId", + table: "Tags"); + + migrationBuilder.DropTable( + name: "FeatureTags"); + + migrationBuilder.AddForeignKey( + name: "FK_Tags_Features_ProjectId", + table: "Tags", + column: "ProjectId", + principalTable: "Features", + principalColumn: "Id"); + } + } +} diff --git a/src/api/MicCheck.Api/Migrations/MicCheckDbContextModelSnapshot.cs b/src/api/MicCheck.Api/Migrations/MicCheckDbContextModelSnapshot.cs index 84d3817..c92522d 100644 --- a/src/api/MicCheck.Api/Migrations/MicCheckDbContextModelSnapshot.cs +++ b/src/api/MicCheck.Api/Migrations/MicCheckDbContextModelSnapshot.cs @@ -22,6 +22,21 @@ namespace MicCheck.Api.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("FeatureTags", b => + { + b.Property("FeatureId") + .HasColumnType("integer"); + + b.Property("TagsId") + .HasColumnType("integer"); + + b.HasKey("FeatureId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("FeatureTags"); + }); + modelBuilder.Entity("MicCheck.Api.ApiKeys.ApiKey", b => { b.Property("Id") @@ -694,6 +709,21 @@ namespace MicCheck.Api.Migrations 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) @@ -748,10 +778,10 @@ namespace MicCheck.Api.Migrations modelBuilder.Entity("MicCheck.Api.Features.Tag", b => { - b.HasOne("MicCheck.Api.Features.Feature", null) - .WithMany("Tags") + b.HasOne("MicCheck.Api.Projects.Project", null) + .WithMany() .HasForeignKey("ProjectId") - .OnDelete(DeleteBehavior.NoAction) + .OnDelete(DeleteBehavior.Cascade) .IsRequired(); }); @@ -839,8 +869,6 @@ namespace MicCheck.Api.Migrations modelBuilder.Entity("MicCheck.Api.Features.Feature", b => { b.Navigation("FeatureStates"); - - b.Navigation("Tags"); }); modelBuilder.Entity("MicCheck.Api.Features.FeatureSegment", b => diff --git a/src/api/MicCheck.Api/Organizations/OrganizationsController.cs b/src/api/MicCheck.Api/Organizations/OrganizationsController.cs index 6d0abb6..fbe9776 100644 --- a/src/api/MicCheck.Api/Organizations/OrganizationsController.cs +++ b/src/api/MicCheck.Api/Organizations/OrganizationsController.cs @@ -149,7 +149,7 @@ public class OrganizationsController(OrganizationService organizationService, We private int? GetCurrentUserId() { - var claim = User.FindFirst("UserId")?.Value; + var claim = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; return int.TryParse(claim, out var id) ? id : null; } }