Initial commit, project structure and domain models

This commit is contained in:
James Wampler
2026-04-07 09:14:06 -07:00
commit 7b15086fe5
111 changed files with 19818 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
module.exports = {
presets: [
['@babel/preset-env', {
targets: { node: 'current' },
modules: 'commonjs',
}],
['@babel/preset-typescript', {
allExtensions: true,
isTSX: true,
}],
],
};
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+40
View File
@@ -0,0 +1,40 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="MicCheck microphone logo">
<title>MicCheck</title>
<!-- Microphone capsule body -->
<rect x="22" y="6" width="20" height="28" rx="10" ry="10"
fill="#FFFFFF" stroke="#BBDEFB" stroke-width="1.5"/>
<!-- Microphone grille lines -->
<line x1="22" y1="16" x2="42" y2="16" stroke="#90CAF9" stroke-width="1" stroke-linecap="round"/>
<line x1="22" y1="22" x2="42" y2="22" stroke="#90CAF9" stroke-width="1" stroke-linecap="round"/>
<line x1="22" y1="28" x2="42" y2="28" stroke="#90CAF9" stroke-width="1" stroke-linecap="round"/>
<!-- Neck / stem going down from capsule -->
<rect x="30" y="34" width="4" height="8" rx="1"
fill="#BBDEFB"/>
<!-- Curved arm (yoke) of the stand -->
<path d="M18 26 Q18 44 32 44 Q46 44 46 26"
fill="none" stroke="#BBDEFB" stroke-width="2.5" stroke-linecap="round"/>
<!-- Vertical stand pole -->
<rect x="30" y="44" width="4" height="12" rx="1"
fill="#BBDEFB"/>
<!-- Horizontal base -->
<rect x="18" y="56" width="28" height="4" rx="2"
fill="#FFFFFF" stroke="#BBDEFB" stroke-width="1.5"/>
<!-- Sound wave arcs (left) -->
<path d="M15 18 Q10 26 15 34" fill="none" stroke="rgba(255,255,255,0.7)"
stroke-width="2" stroke-linecap="round"/>
<path d="M10 14 Q3 26 10 38" fill="none" stroke="rgba(255,255,255,0.4)"
stroke-width="2" stroke-linecap="round"/>
<!-- Sound wave arcs (right) -->
<path d="M49 18 Q54 26 49 34" fill="none" stroke="rgba(255,255,255,0.7)"
stroke-width="2" stroke-linecap="round"/>
<path d="M54 14 Q61 26 54 38" fill="none" stroke="rgba(255,255,255,0.4)"
stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+1
View File
@@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta http-equiv="X-UA-Compatible" content="IE=edge"/><title>MicCheck Admin</title></head><body><div id="app"></div><script defer="defer" src="/js/929.737c1237.js"></script><script defer="defer" src="/js/main.c5e9ac61.js"></script></body></html>
File diff suppressed because one or more lines are too long
+29
View File
@@ -0,0 +1,29 @@
/*!
* vue-router v4.6.4
* (c) 2025 Eduardo San Martin Morote
* @license MIT
*/
/**
* WCAG 3.0 APCA perceptual contrast algorithm from https://github.com/Myndex/SAPC-APCA
* @licence https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document
* @see https://www.w3.org/WAI/GL/task-forces/silver/wiki/Visual_Contrast_of_Text_Subgroup
*/
/**
* @vue/reactivity v3.5.31
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
**/
/**
* @vue/runtime-dom v3.5.31
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
**/
/**
* @vue/shared v3.5.31
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
**/
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+36
View File
@@ -0,0 +1,36 @@
/** @type {import('jest').Config} */
module.exports = {
testEnvironment: 'jsdom',
testEnvironmentOptions: {
customExportConditions: ['node', 'node-addons'],
},
roots: ['<rootDir>/src', '<rootDir>/tests'],
testMatch: [
'**/__tests__/**/*.spec.[jt]s?(x)',
'**/*.spec.[jt]s?(x)',
'**/*.test.[jt]s?(x)',
],
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'vue', 'json'],
transform: {
'^.+\\.vue$': '@vue/vue3-jest',
'^.+\\.[jt]sx?$': 'babel-jest',
},
transformIgnorePatterns: [
'/node_modules/(?!(vuetify)/)',
],
moduleNameMapper: {
'\\.(css|scss|sass)$': '<rootDir>/tests/__mocks__/styleMock.js',
'\\.(svg|png|jpg|jpeg|gif|webp|woff2?)$': '<rootDir>/tests/__mocks__/fileMock.js',
'^@/(.*)$': '<rootDir>/src/$1',
},
globals: {
'vue-jest': {
tsConfig: './tsconfig.json',
},
},
setupFilesAfterEnv: ['<rootDir>/tests/setup.ts'],
collectCoverageFrom: [
'src/**/*.{ts,vue}',
'!src/main.ts',
],
};
+12940
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
{
"name": "mic-check-admin",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "webpack --mode production --config webpack.config.js",
"build:dev": "webpack --mode development --config webpack.config.js",
"serve": "webpack serve --mode development --config webpack.config.js",
"test": "jest --passWithNoTests"
},
"dependencies": {
"@mdi/font": "^7.4.47",
"vue": "^3.4.0",
"vue-router": "^4.3.0",
"vuetify": "^3.6.0"
},
"devDependencies": {
"@babel/core": "^7.24.0",
"@babel/preset-env": "^7.24.0",
"@babel/preset-typescript": "^7.24.0",
"@types/jest": "^29.5.12",
"@types/node": "^20.12.0",
"@vue/test-utils": "^2.4.6",
"@vue/vue3-jest": "^29.2.6",
"babel-jest": "^29.7.0",
"css-loader": "^7.1.2",
"html-webpack-plugin": "^5.6.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"sass": "^1.77.2",
"sass-loader": "^14.2.1",
"style-loader": "^4.0.0",
"ts-jest": "^29.1.4",
"ts-loader": "^9.5.1",
"typescript": "^5.4.5",
"vue-loader": "^17.4.2",
"webpack": "^5.91.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^5.0.4",
"webpack-plugin-vuetify": "^3.1.0"
},
"overrides": {
"brace-expansion": "^5.0.5"
}
}
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>MicCheck Admin</title>
</head>
<body>
<div id="app"></div>
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
<template>
<AppLayout />
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import AppLayout from '@/components/AppLayout.vue';
export default defineComponent({
name: 'App',
components: { AppLayout },
});
</script>
+40
View File
@@ -0,0 +1,40 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="MicCheck microphone logo">
<title>MicCheck</title>
<!-- Microphone capsule body -->
<rect x="22" y="6" width="20" height="28" rx="10" ry="10"
fill="#FFFFFF" stroke="#BBDEFB" stroke-width="1.5"/>
<!-- Microphone grille lines -->
<line x1="22" y1="16" x2="42" y2="16" stroke="#90CAF9" stroke-width="1" stroke-linecap="round"/>
<line x1="22" y1="22" x2="42" y2="22" stroke="#90CAF9" stroke-width="1" stroke-linecap="round"/>
<line x1="22" y1="28" x2="42" y2="28" stroke="#90CAF9" stroke-width="1" stroke-linecap="round"/>
<!-- Neck / stem going down from capsule -->
<rect x="30" y="34" width="4" height="8" rx="1"
fill="#BBDEFB"/>
<!-- Curved arm (yoke) of the stand -->
<path d="M18 26 Q18 44 32 44 Q46 44 46 26"
fill="none" stroke="#BBDEFB" stroke-width="2.5" stroke-linecap="round"/>
<!-- Vertical stand pole -->
<rect x="30" y="44" width="4" height="12" rx="1"
fill="#BBDEFB"/>
<!-- Horizontal base -->
<rect x="18" y="56" width="28" height="4" rx="2"
fill="#FFFFFF" stroke="#BBDEFB" stroke-width="1.5"/>
<!-- Sound wave arcs (left) -->
<path d="M15 18 Q10 26 15 34" fill="none" stroke="rgba(255,255,255,0.7)"
stroke-width="2" stroke-linecap="round"/>
<path d="M10 14 Q3 26 10 38" fill="none" stroke="rgba(255,255,255,0.4)"
stroke-width="2" stroke-linecap="round"/>
<!-- Sound wave arcs (right) -->
<path d="M49 18 Q54 26 49 34" fill="none" stroke="rgba(255,255,255,0.7)"
stroke-width="2" stroke-linecap="round"/>
<path d="M54 14 Q61 26 54 38" fill="none" stroke="rgba(255,255,255,0.4)"
stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+79
View File
@@ -0,0 +1,79 @@
<template>
<v-app-bar
color="primary"
elevation="2"
height="64"
>
<template #prepend>
<v-app-bar-title class="d-flex align-center">
<img
:src="logoUrl"
alt="MicCheck logo"
height="40"
width="40"
class="mr-3"
data-testid="app-logo"
/>
<span class="text-h6 font-weight-bold text-white">MicCheck</span>
</v-app-bar-title>
</template>
<!-- Center: Main navigation links (hidden on xs/sm) -->
<div class="d-none d-md-flex align-center ga-2 mx-auto">
<v-btn
v-for="link in navLinks"
:key="link.route"
:to="link.route"
variant="text"
color="white"
rounded="lg"
:data-testid="`nav-link-${link.label.toLowerCase()}`"
>
{{ link.label }}
</v-btn>
</div>
<template #append>
<div class="d-flex align-center ga-2 mr-2">
<v-btn
to="/profile"
variant="text"
color="white"
prepend-icon="mdi-account-circle"
data-testid="profile-link"
rounded="lg"
>
John Doe
</v-btn>
<v-btn
to="/settings"
icon
variant="text"
color="white"
data-testid="settings-link"
>
<v-icon>mdi-cog-outline</v-icon>
</v-btn>
</div>
</template>
</v-app-bar>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import logoUrl from '@/assets/logo.svg';
export default defineComponent({
name: 'AppHeader',
setup() {
const navLinks = [
{ label: 'Features', route: '/features' },
{ label: 'Environments', route: '/environments' },
{ label: 'Projects', route: '/projects' },
];
return { navLinks, logoUrl };
},
});
</script>
+64
View File
@@ -0,0 +1,64 @@
<template>
<v-app :theme="theme">
<AppHeader />
<v-navigation-drawer
v-model="drawer"
:rail="rail"
permanent
color="white"
>
<v-list density="compact" nav>
<v-list-item
v-for="link in navLinks"
:key="link.route"
:to="link.route"
:prepend-icon="link.icon"
:title="link.label"
active-color="primary"
rounded="lg"
/>
</v-list>
<template #append>
<v-list density="compact" nav>
<v-list-item
:prepend-icon="rail ? 'mdi-chevron-right' : 'mdi-chevron-left'"
title="Collapse"
@click="rail = !rail"
/>
</v-list>
</template>
</v-navigation-drawer>
<v-main>
<v-container fluid class="pa-6">
<router-view />
</v-container>
</v-main>
</v-app>
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue';
import AppHeader from '@/components/AppHeader.vue';
export default defineComponent({
name: 'AppLayout',
components: { AppHeader },
setup() {
const drawer = ref(true);
const rail = ref(false);
const theme = ref('light');
const navLinks = [
{ label: 'Dashboard', route: '/', icon: 'mdi-view-dashboard' },
{ label: 'Features', route: '/features', icon: 'mdi-flag-outline' },
{ label: 'Environments', route: '/environments', icon: 'mdi-server-outline' },
{ label: 'Projects', route: '/projects', icon: 'mdi-folder-outline' },
];
return { drawer, rail, theme, navLinks };
},
});
</script>
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
import vuetify from './plugins/vuetify';
import '@mdi/font/css/materialdesignicons.css';
const app = createApp(App);
app.use(router);
app.use(vuetify);
app.mount('#app');
+29
View File
@@ -0,0 +1,29 @@
import 'vuetify/styles';
import { createVuetify } from 'vuetify';
import { aliases, mdi } from 'vuetify/iconsets/mdi';
export default createVuetify({
icons: {
defaultSet: 'mdi',
aliases,
sets: { mdi },
},
theme: {
defaultTheme: 'light',
themes: {
light: {
colors: {
primary: '#1565C0',
secondary: '#546E7A',
accent: '#00ACC1',
error: '#D32F2F',
info: '#0288D1',
success: '#388E3C',
warning: '#F57C00',
background: '#F5F7FA',
surface: '#FFFFFF',
},
},
},
},
});
+51
View File
@@ -0,0 +1,51 @@
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
import DashboardView from '@/views/DashboardView.vue';
import FeaturesView from '@/views/FeaturesView.vue';
import EnvironmentsView from '@/views/EnvironmentsView.vue';
import ProjectsView from '@/views/ProjectsView.vue';
import ProfileView from '@/views/ProfileView.vue';
import SettingsView from '@/views/SettingsView.vue';
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'Dashboard',
component: DashboardView,
},
{
path: '/features',
name: 'Features',
component: FeaturesView,
},
{
path: '/environments',
name: 'Environments',
component: EnvironmentsView,
},
{
path: '/projects',
name: 'Projects',
component: ProjectsView,
},
{
path: '/profile',
name: 'Profile',
component: ProfileView,
},
{
path: '/settings',
name: 'Settings',
component: SettingsView,
},
{
path: '/:pathMatch(.*)*',
redirect: '/',
},
];
const router = createRouter({
history: createWebHistory(),
routes,
});
export default router;
+10
View File
@@ -0,0 +1,10 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, unknown>
export default component
}
declare module '*.svg' {
const src: string
export default src
}
+18
View File
@@ -0,0 +1,18 @@
<template>
<v-row>
<v-col cols="12">
<h1 class="text-h4 mb-4">Dashboard</h1>
<v-card>
<v-card-text>
Welcome to MicCheck. Use the navigation to manage your features,
environments, and projects.
</v-card-text>
</v-card>
</v-col>
</v-row>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({ name: 'DashboardView' });
</script>
+17
View File
@@ -0,0 +1,17 @@
<template>
<v-row>
<v-col cols="12">
<h1 class="text-h4 mb-4">Environments</h1>
<v-card>
<v-card-text>
Configure deployment environments such as production, staging, and development.
</v-card-text>
</v-card>
</v-col>
</v-row>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({ name: 'EnvironmentsView' });
</script>
+17
View File
@@ -0,0 +1,17 @@
<template>
<v-row>
<v-col cols="12">
<h1 class="text-h4 mb-4">Features</h1>
<v-card>
<v-card-text>
Manage feature flags across projects and environments.
</v-card-text>
</v-card>
</v-col>
</v-row>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({ name: 'FeaturesView' });
</script>
+17
View File
@@ -0,0 +1,17 @@
<template>
<v-row>
<v-col cols="12">
<h1 class="text-h4 mb-4">Profile</h1>
<v-card>
<v-card-text>
Manage your account details and preferences. Logged in as John Doe.
</v-card-text>
</v-card>
</v-col>
</v-row>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({ name: 'ProfileView' });
</script>
+17
View File
@@ -0,0 +1,17 @@
<template>
<v-row>
<v-col cols="12">
<h1 class="text-h4 mb-4">Projects</h1>
<v-card>
<v-card-text>
Organize your applications into projects to manage flags independently.
</v-card-text>
</v-card>
</v-col>
</v-row>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({ name: 'ProjectsView' });
</script>
+17
View File
@@ -0,0 +1,17 @@
<template>
<v-row>
<v-col cols="12">
<h1 class="text-h4 mb-4">Settings</h1>
<v-card>
<v-card-text>
Application-wide configuration and administration settings.
</v-card-text>
</v-card>
</v-col>
</v-row>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({ name: 'SettingsView' });
</script>
+1
View File
@@ -0,0 +1 @@
module.exports = 'test-file-stub';
+1
View File
@@ -0,0 +1 @@
module.exports = {};
+21
View File
@@ -0,0 +1,21 @@
import { config } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import * as components from 'vuetify/components';
import * as directives from 'vuetify/directives';
// Stub ResizeObserver which jsdom doesn't implement
global.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
};
// Stub CSS.supports
Object.defineProperty(window, 'CSS', {
value: { supports: () => false },
writable: true,
});
const vuetify = createVuetify({ components, directives });
config.global.plugins = [vuetify];
+68
View File
@@ -0,0 +1,68 @@
import { describe, it, expect, beforeEach } from '@jest/globals';
import { mount, VueWrapper } from '@vue/test-utils';
import { createRouter, createMemoryHistory } from 'vue-router';
import AppHeader from '@/components/AppHeader.vue';
function makeRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div />' } },
{ path: '/features', component: { template: '<div />' } },
{ path: '/environments', component: { template: '<div />' } },
{ path: '/projects', component: { template: '<div />' } },
{ path: '/profile', component: { template: '<div />' } },
{ path: '/settings', component: { template: '<div />' } },
],
});
}
describe('AppHeader', () => {
let wrapper: VueWrapper;
beforeEach(async () => {
const router = makeRouter();
await router.push('/');
// v-app-bar requires a v-app layout context — wrap the component under test
wrapper = mount(
{ template: '<v-app><AppHeader /></v-app>', components: { AppHeader } },
{ global: { plugins: [router] } },
);
});
it('renders the Features navigation link', () => {
const link = wrapper.find('[data-testid="nav-link-features"]');
expect(link.exists()).toBe(true);
expect(link.text()).toBe('Features');
});
it('renders the Environments navigation link', () => {
const link = wrapper.find('[data-testid="nav-link-environments"]');
expect(link.exists()).toBe(true);
expect(link.text()).toBe('Environments');
});
it('renders the Projects navigation link', () => {
const link = wrapper.find('[data-testid="nav-link-projects"]');
expect(link.exists()).toBe(true);
expect(link.text()).toBe('Projects');
});
it('renders the SVG microphone logo image', () => {
const logo = wrapper.find('[data-testid="app-logo"]');
expect(logo.exists()).toBe(true);
expect(logo.attributes('alt')).toBe('MicCheck logo');
});
it('renders the profile link with John Doe text', () => {
const link = wrapper.find('[data-testid="profile-link"]');
expect(link.exists()).toBe(true);
expect(link.text()).toContain('John Doe');
});
it('renders the settings gear icon link', () => {
const link = wrapper.find('[data-testid="settings-link"]');
expect(link.exists()).toBe(true);
});
});
+32
View File
@@ -0,0 +1,32 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"jsx": "preserve",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noImplicitAny": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"types": ["node", "jest"]
},
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.vue",
"tests/**/*.ts"
],
"exclude": [
"node_modules",
"dist"
]
}
+100
View File
@@ -0,0 +1,100 @@
const path = require('path');
const { VueLoaderPlugin } = require('vue-loader');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { VuetifyPlugin } = require('webpack-plugin-vuetify');
module.exports = (env, argv) => {
const isProd = argv.mode === 'production';
return {
entry: './src/main.ts',
output: {
filename: isProd ? 'js/[name].[contenthash:8].js' : 'js/[name].js',
path: path.resolve(__dirname, 'dist'),
publicPath: '/',
clean: true,
},
resolve: {
extensions: ['.ts', '.tsx', '.js', '.vue', '.json'],
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
module: {
rules: [
{
test: /\.vue$/,
loader: 'vue-loader',
},
{
test: /\.tsx?$/,
loader: 'ts-loader',
exclude: /node_modules/,
options: {
appendTsSuffixTo: [/\.vue$/],
transpileOnly: false,
},
},
{
test: /\.s[ac]ss$/,
use: ['style-loader', 'css-loader', 'sass-loader'],
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},
{
test: /\.svg$/,
type: 'asset/resource',
generator: {
filename: 'img/[name].[hash:8][ext]',
},
},
{
test: /\.(png|jpe?g|gif|webp)$/,
type: 'asset/resource',
generator: {
filename: 'img/[name].[hash:8][ext]',
},
},
{
test: /\.(woff2?|eot|ttf|otf)$/,
type: 'asset/resource',
generator: {
filename: 'fonts/[name].[hash:8][ext]',
},
},
],
},
plugins: [
new VueLoaderPlugin(),
new VuetifyPlugin({ autoImport: true }),
new HtmlWebpackPlugin({
template: './public/index.html',
filename: 'index.html',
inject: 'body',
}),
],
devServer: {
port: 8080,
hot: true,
historyApiFallback: true,
static: {
directory: path.resolve(__dirname, 'public'),
},
},
devtool: isProd ? 'source-map' : 'eval-cheap-module-source-map',
optimization: {
splitChunks: {
chunks: 'all',
},
},
};
};
+13
View File
@@ -0,0 +1,13 @@
namespace MicCheck.Api.ApiKeys;
public class ApiKey
{
public int Id { get; init; }
public required string Key { get; set; }
public required string Prefix { get; set; }
public required string Name { get; set; }
public int OrganizationId { get; init; }
public bool IsActive { get; set; }
public DateTimeOffset? ExpiresAt { get; set; }
public DateTimeOffset CreatedAt { get; init; }
}
View File
+15
View File
@@ -0,0 +1,15 @@
namespace MicCheck.Api.Audit;
public class AuditLog
{
public int Id { get; init; }
public required string ResourceType { get; init; }
public required string ResourceId { get; init; }
public required string Action { get; init; }
public string? Changes { get; init; }
public int OrganizationId { get; init; }
public int? ProjectId { get; init; }
public int? EnvironmentId { get; init; }
public int? ActorUserId { get; init; }
public DateTimeOffset CreatedAt { get; init; }
}
@@ -0,0 +1,23 @@
using Microsoft.AspNetCore.Mvc;
namespace MicCheck.Api.Auth;
public static class AuthEndpoints
{
public static void MapAuthEndpoints(this WebApplication app)
{
app.MapPost("/auth/token", (
[FromBody] TokenRequest request,
ITokenService tokenService) =>
{
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
return Results.BadRequest("Username and password are required.");
var response = tokenService.GenerateToken(request.Username);
return Results.Ok(response);
})
.WithName("GetToken")
.WithTags("Auth")
.AllowAnonymous();
}
}
@@ -0,0 +1,6 @@
namespace MicCheck.Api.Auth;
public interface ITokenService
{
TokenResponse GenerateToken(string username);
}
@@ -0,0 +1,3 @@
namespace MicCheck.Api.Auth;
public record TokenRequest(string Username, string Password);
@@ -0,0 +1,3 @@
namespace MicCheck.Api.Auth;
public record TokenResponse(string Token, DateTime ExpiresAt);
+43
View File
@@ -0,0 +1,43 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.IdentityModel.Tokens;
namespace MicCheck.Api.Auth;
public class TokenService(IConfiguration configuration) : ITokenService
{
public TokenResponse GenerateToken(string username)
{
var secretKey = configuration["Jwt:SecretKey"]
?? throw new InvalidOperationException("Jwt:SecretKey is not configured.");
var issuer = configuration["Jwt:Issuer"]
?? throw new InvalidOperationException("Jwt:Issuer is not configured.");
var audience = configuration["Jwt:Audience"]
?? throw new InvalidOperationException("Jwt:Audience is not configured.");
var expiryMinutes = int.Parse(configuration["Jwt:ExpiryMinutes"] ?? "60");
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey));
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
var expiresAt = DateTime.UtcNow.AddMinutes(expiryMinutes);
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Sub, username),
new Claim(JwtRegisteredClaimNames.Name, username),
new Claim(JwtRegisteredClaimNames.Iat,
DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(),
ClaimValueTypes.Integer64),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
};
var token = new JwtSecurityToken(
issuer: issuer,
audience: audience,
claims: claims,
expires: expiresAt,
signingCredentials: credentials);
return new TokenResponse(new JwtSecurityTokenHandler().WriteToken(token), expiresAt);
}
}
+19
View File
@@ -0,0 +1,19 @@
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
WORKDIR /app
EXPOSE 8080
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY ["src/api/MicCheck.Api/MicCheck.Api.csproj", "src/api/MicCheck.Api/"]
COPY ["src/infrastructure/MicCheck.Infrastructure/MicCheck.Infrastructure.csproj", "src/infrastructure/MicCheck.Infrastructure/"]
RUN dotnet restore "src/api/MicCheck.Api/MicCheck.Api.csproj"
COPY . .
RUN dotnet build "src/api/MicCheck.Api/MicCheck.Api.csproj" -c Release -o /app/build
FROM build AS publish
RUN dotnet publish "src/api/MicCheck.Api/MicCheck.Api.csproj" -c Release -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "MicCheck.Api.dll"]
@@ -0,0 +1,13 @@
using MicCheck.Api.Features;
namespace MicCheck.Api.Environments;
public class Environment
{
public int Id { get; init; }
public required string Name { get; set; }
public required string ApiKey { get; set; }
public int ProjectId { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public ICollection<FeatureState> FeatureStates { get; init; } = [];
}
+17
View File
@@ -0,0 +1,17 @@
namespace MicCheck.Api.Features;
public class Feature
{
public int Id { get; init; }
public required string Name { get; set; }
public FeatureType Type { get; set; }
public string? InitialValue { get; set; }
public string? Description { get; set; }
public bool DefaultEnabled { get; set; }
public int ProjectId { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public ICollection<FeatureState> FeatureStates { get; init; } = [];
public ICollection<Tag> Tags { get; init; } = [];
}
public enum FeatureType { Standard, MultiVariate }
@@ -0,0 +1,11 @@
namespace MicCheck.Api.Features;
public class FeatureSegment
{
public int Id { get; init; }
public int FeatureId { get; init; }
public int SegmentId { get; init; }
public int EnvironmentId { get; init; }
public int Priority { get; set; }
public FeatureState? FeatureState { get; set; }
}
@@ -0,0 +1,15 @@
namespace MicCheck.Api.Features;
public class FeatureState
{
public int Id { get; init; }
public int FeatureId { get; init; }
public int EnvironmentId { get; init; }
public int? IdentityId { get; set; }
public bool Enabled { get; set; }
public string? Value { get; set; }
public int? FeatureSegmentId { get; set; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; set; }
public int Version { get; set; }
}
@@ -0,0 +1,8 @@
namespace MicCheck.Api.Features;
public class FeatureStateResult
{
public required Feature Feature { get; init; }
public bool Enabled { get; init; }
public string? Value { get; init; }
}
+9
View File
@@ -0,0 +1,9 @@
namespace MicCheck.Api.Features;
public class Tag
{
public int Id { get; init; }
public required string Label { get; set; }
public required string Color { get; set; }
public int ProjectId { get; init; }
}
@@ -0,0 +1,13 @@
using MicCheck.Api.Features;
namespace MicCheck.Api.Identities;
public class Identity
{
public int Id { get; init; }
public required string Identifier { get; set; }
public int EnvironmentId { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public ICollection<IdentityTrait> Traits { get; init; } = [];
public ICollection<FeatureState> FeatureStateOverrides { get; init; } = [];
}
@@ -0,0 +1,12 @@
namespace MicCheck.Api.Identities;
public class IdentityTrait
{
public int Id { get; init; }
public int IdentityId { get; init; }
public required string Key { get; set; }
public required string Value { get; set; }
public TraitValueType ValueType { get; set; }
}
public enum TraitValueType { String, Integer, Float, Boolean }
+24
View File
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.5" />
<PackageReference Include="Scalar.AspNetCore" Version="2.6.0" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.16.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../infrastructure/MicCheck.Infrastructure/MicCheck.Infrastructure.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,14 @@
using MicCheck.Api.ApiKeys;
using MicCheck.Api.Projects;
namespace MicCheck.Api.Organizations;
public class Organization
{
public int Id { get; init; }
public required string Name { get; set; }
public DateTimeOffset CreatedAt { get; init; }
public ICollection<Project> Projects { get; init; } = [];
public ICollection<OrganizationUser> Members { get; init; } = [];
public ICollection<ApiKey> ApiKeys { get; init; } = [];
}
@@ -0,0 +1,10 @@
namespace MicCheck.Api.Organizations;
public class OrganizationUser
{
public int OrganizationId { get; init; }
public int UserId { get; init; }
public OrganizationRole Role { get; set; }
}
public enum OrganizationRole { User, Admin }
+86
View File
@@ -0,0 +1,86 @@
using System.Text;
using System.Threading.RateLimiting;
using FluentValidation;
using FluentValidation.AspNetCore;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.IdentityModel.Tokens;
using MicCheck.Api.Auth;
using Scalar.AspNetCore;
using Serilog;
Log.Logger = new LoggerConfiguration()
.WriteTo.Console()
.CreateBootstrapLogger();
try
{
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseSerilog((context, services, config) =>
config.ReadFrom.Configuration(context.Configuration)
.ReadFrom.Services(services)
.WriteTo.Console());
builder.Services.AddOpenApi();
builder.Services.AddControllers();
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:SecretKey"]!))
};
});
builder.Services.AddAuthorization();
builder.Services.AddRateLimiter(options =>
options.AddFixedWindowLimiter("AdminApi", limiter =>
{
limiter.PermitLimit = 500;
limiter.Window = TimeSpan.FromMinutes(1);
limiter.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
limiter.QueueLimit = 0;
}));
builder.Services.AddFluentValidationAutoValidation();
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
builder.Services.AddScoped<ITokenService, TokenService>();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
app.UseSerilogRequestLogging();
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapAuthEndpoints();
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "Application terminated unexpectedly");
}
finally
{
Log.CloseAndFlush();
}
public partial class Program { }
+17
View File
@@ -0,0 +1,17 @@
using MicCheck.Api.Features;
using MicCheck.Api.Segments;
using AppEnvironment = MicCheck.Api.Environments.Environment;
namespace MicCheck.Api.Projects;
public class Project
{
public int Id { get; init; }
public required string Name { get; set; }
public int OrganizationId { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public bool HideDisabledFlags { get; set; }
public ICollection<AppEnvironment> Environments { get; init; } = [];
public ICollection<Feature> Features { get; init; } = [];
public ICollection<Segment> Segments { get; init; } = [];
}
+10
View File
@@ -0,0 +1,10 @@
namespace MicCheck.Api.Segments;
public class Segment
{
public int Id { get; init; }
public required string Name { get; set; }
public int ProjectId { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public ICollection<SegmentRule> Rules { get; init; } = [];
}
@@ -0,0 +1,31 @@
namespace MicCheck.Api.Segments;
public class SegmentCondition
{
public int Id { get; init; }
public int RuleId { get; init; }
public required string Property { get; set; }
public SegmentConditionOperator Operator { get; set; }
public required string Value { get; set; }
}
public enum SegmentConditionOperator
{
Equal,
NotEqual,
GreaterThan,
GreaterThanOrEqual,
LessThan,
LessThanOrEqual,
Contains,
NotContains,
Regex,
IsSet,
IsNotSet,
In,
NotIn,
PercentageSplit,
ModuloValueDivisorRemainder,
IsTrue,
IsFalse
}
@@ -0,0 +1,13 @@
namespace MicCheck.Api.Segments;
public class SegmentRule
{
public int Id { get; init; }
public int SegmentId { get; init; }
public int? ParentRuleId { get; set; }
public SegmentRuleType Type { get; set; }
public ICollection<SegmentCondition> Conditions { get; init; } = [];
public ICollection<SegmentRule> ChildRules { get; init; } = [];
}
public enum SegmentRuleType { All, Any, None }
View File
+17
View File
@@ -0,0 +1,17 @@
using MicCheck.Api.Organizations;
namespace MicCheck.Api.Users;
public class User
{
public int Id { get; init; }
public required string Email { get; set; }
public required string PasswordHash { get; set; }
public required string FirstName { get; set; }
public required string LastName { get; set; }
public bool IsActive { get; set; }
public bool IsTwoFactorEnabled { get; set; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? LastLoginAt { get; set; }
public ICollection<OrganizationUser> Organizations { get; init; } = [];
}
+15
View File
@@ -0,0 +1,15 @@
namespace MicCheck.Api.Webhooks;
public class Webhook
{
public int Id { get; init; }
public required string Url { get; set; }
public string? Secret { get; set; }
public WebhookScope Scope { get; set; }
public int? EnvironmentId { get; set; }
public int? OrganizationId { get; set; }
public bool Enabled { get; set; }
public DateTimeOffset CreatedAt { get; init; }
}
public enum WebhookScope { Environment, Organization }
@@ -0,0 +1,15 @@
namespace MicCheck.Api.Webhooks;
public class WebhookDeliveryLog
{
public int Id { get; init; }
public int WebhookId { get; init; }
public required string EventType { get; init; }
public required string PayloadJson { get; init; }
public int? ResponseStatusCode { get; set; }
public string? ResponseBody { get; set; }
public bool Success { get; set; }
public string? ErrorMessage { get; set; }
public DateTimeOffset AttemptedAt { get; init; }
public TimeSpan Duration { get; set; }
}
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"Microsoft.AspNetCore": "Information"
}
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
}
}
},
"AllowedHosts": "*",
"Jwt": {
"SecretKey": "change-this-secret-key-in-production-must-be-32-chars",
"Issuer": "MicCheck",
"Audience": "MicCheck",
"ExpiryMinutes": 60
},
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Database=miccheck;Username=miccheck;Password=password"
}
}
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
</ItemGroup>
</Project>