feat(flags): real feature-flag system with bulk eval + operator UI
Real backend for the flags page (was pure mock). Built so it's ready for
the first risky rollout (likely the Stalwart JMAP client or the Stripe
billing engine).
services/platform-api:
- Flag schema (key, description, state, pct, scope.{plans, tenantSlugs,
partnerSlugs, environments}, embedded history capped at 20)
- FlagsService with CRUD + evaluateAll(tenantSlug) → { key: bool }
Eval algorithm:
off → false; on → true
targeted → require non-empty scope (empty allowlist means "nobody"),
then match every non-empty axis
rollout → match scope, then sha256(`${tenantId}:${key}`) % 100 < pct
Hash-based rollout is deterministic: bumping pct only flips the new
slice. Pure helpers (matchesScope, hasAnyScope, inRolloutBucket) are
exported for future unit tests.
- FlagsController exposes GET /flags, GET /flags/:key, POST /flags/evaluate
(JwtAuthGuard); POST/PATCH/DELETE require OperatorGuard. History entries
capture the actor's email.
- SeedService idempotently creates 10 flag keys mapping to real Dezky
concerns (jmap_native_v2, gdpr_export_v2, new_billing_engine, etc.).
$setOnInsert so operator edits survive restarts.
apps/operator:
- 6 proxies: /api/flags index get/post, [key] get/patch/delete, evaluate post
- types/flag.ts with the shape that mirrors the backend
- pages/flags.vue: useFetch real list, row click opens FlagDetail,
"New flag" opens NewFlagModal, scope summary column shows targeting
at a glance
- FlagDetail.vue: side panel with segmented state, rollout slider with
live "~N of M tenants" preview from /api/tenants, plan/tenant/env chip
pickers, dirty-tracked Save, instant Kill-switch (PATCH state=off+pct=0),
embedded change history
- NewFlagModal.vue: minimal create form (key + description). Everything
else is configured in the detail panel afterward.
- CommandPalette: feature-flag rows now come from /api/flags instead of
the dropped fixture, so newly-created flags are searchable immediately
- data/fixtures.ts: drop FLAGS / FeatureFlag exports (replaced by the
real backend)
Smoke-tested end-to-end: list renders 10 seed flags, opening gdpr_export_v2
and flipping to rollout 25% then saving persists + adds a history entry,
kill-switch sets state=off in one click, /api/flags/evaluate returns the
correct booleans for the seeded tenant, same tenant gets the same answer
on consecutive evals (determinism), and creating + deleting a flag through
the UI roundtrips correctly.
This commit is contained in:
@@ -2,7 +2,8 @@
|
|||||||
import type { IconName } from './UiIcon.vue'
|
import type { IconName } from './UiIcon.vue'
|
||||||
import type { Tenant } from '~/types/tenant'
|
import type { Tenant } from '~/types/tenant'
|
||||||
import type { Partner } from '~/types/partner'
|
import type { Partner } from '~/types/partner'
|
||||||
import { FLAGS, INCIDENT } from '~/data/fixtures'
|
import type { Flag } from '~/types/flag'
|
||||||
|
import { INCIDENT } from '~/data/fixtures'
|
||||||
|
|
||||||
// Each row in the palette. `action` decides what happens on Enter / click.
|
// Each row in the palette. `action` decides what happens on Enter / click.
|
||||||
// We try `to` first (a navigateTo) since most rows are navigation; `run` is
|
// We try `to` first (a navigateTo) since most rows are navigation; `run` is
|
||||||
@@ -31,6 +32,7 @@ const inputRef = ref<HTMLInputElement | null>(null)
|
|||||||
// palette is client-only).
|
// palette is client-only).
|
||||||
const { data: tenants, refresh: rT } = useLazyFetch<Tenant[]>('/api/tenants', { default: () => [], server: false })
|
const { data: tenants, refresh: rT } = useLazyFetch<Tenant[]>('/api/tenants', { default: () => [], server: false })
|
||||||
const { data: partners, refresh: rP } = useLazyFetch<Partner[]>('/api/partners', { default: () => [], server: false })
|
const { data: partners, refresh: rP } = useLazyFetch<Partner[]>('/api/partners', { default: () => [], server: false })
|
||||||
|
const { data: flags, refresh: rF } = useLazyFetch<Flag[]>('/api/flags', { default: () => [], server: false })
|
||||||
|
|
||||||
const NAV: { id: string; label: string; icon: IconName; to: string }[] = [
|
const NAV: { id: string; label: string; icon: IconName; to: string }[] = [
|
||||||
{ id: 'n-overview', label: 'Overview', icon: 'home', to: '/' },
|
{ id: 'n-overview', label: 'Overview', icon: 'home', to: '/' },
|
||||||
@@ -72,12 +74,13 @@ function partnerRows(): Row[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function flagRows(): Row[] {
|
function flagRows(): Row[] {
|
||||||
return FLAGS.slice(0, 6).map((f) => ({
|
return (flags.value ?? []).slice(0, 8).map((f) => ({
|
||||||
id: `f-${f.key}`,
|
id: `f-${f.key}`,
|
||||||
groupLabel: 'Feature flags',
|
groupLabel: 'Feature flags',
|
||||||
title: f.key,
|
title: f.key,
|
||||||
subtitle: f.state === 'rollout' ? `rollout · ${f.pct}% · ${f.scope}` : `${f.state} · ${f.scope}`,
|
subtitle: f.state === 'rollout' ? `rollout · ${f.pct}%` : f.state,
|
||||||
icon: 'plug',
|
icon: 'plug',
|
||||||
|
badge: f.state,
|
||||||
to: '/flags',
|
to: '/flags',
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -154,7 +157,7 @@ watch(isOpen, async (v) => {
|
|||||||
if (!v) return
|
if (!v) return
|
||||||
query.value = ''
|
query.value = ''
|
||||||
cursor.value = 0
|
cursor.value = 0
|
||||||
await Promise.all([rT(), rP()])
|
await Promise.all([rT(), rP(), rF()])
|
||||||
await nextTick()
|
await nextTick()
|
||||||
inputRef.value?.focus()
|
inputRef.value?.focus()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,432 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// Right-anchored side panel for editing a single flag. Opens from a row click
|
||||||
|
// on /flags. Mirrors the NotificationDrawer/IncidentModal dismissal pattern
|
||||||
|
// (Teleport scrim + Escape + route change) and the segmented-state UI from
|
||||||
|
// the operator design.
|
||||||
|
|
||||||
|
import type { Flag, FlagState } from '~/types/flag'
|
||||||
|
import type { Tenant } from '~/types/tenant'
|
||||||
|
|
||||||
|
const props = defineProps<{ flag: Flag | null; tenants: Tenant[] }>()
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
saved: [Flag]
|
||||||
|
deleted: [string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
// Local working copy so unsaved edits aren't visible elsewhere. Reset every
|
||||||
|
// time `flag` changes (opening a different row, or re-opening after save).
|
||||||
|
const draft = ref({
|
||||||
|
description: '',
|
||||||
|
state: 'off' as FlagState,
|
||||||
|
pct: 0,
|
||||||
|
plans: [] as string[],
|
||||||
|
tenantSlugs: [] as string[],
|
||||||
|
partnerSlugs: [] as string[],
|
||||||
|
environments: [] as ('prod' | 'staging' | 'dev')[],
|
||||||
|
note: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.flag,
|
||||||
|
(f) => {
|
||||||
|
if (!f) return
|
||||||
|
draft.value = {
|
||||||
|
description: f.description,
|
||||||
|
state: f.state,
|
||||||
|
pct: f.pct,
|
||||||
|
plans: [...f.scope.plans],
|
||||||
|
tenantSlugs: [...f.scope.tenantSlugs],
|
||||||
|
partnerSlugs: [...f.scope.partnerSlugs],
|
||||||
|
environments: [...f.scope.environments],
|
||||||
|
note: '',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(() => route.path, () => {
|
||||||
|
if (props.flag) emit('close')
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && props.flag) emit('close')
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
|
||||||
|
})
|
||||||
|
|
||||||
|
const STATES: FlagState[] = ['off', 'targeted', 'rollout', 'on']
|
||||||
|
const ALL_PLANS = ['mvp', 'pro', 'enterprise']
|
||||||
|
const ALL_ENVS: ('prod' | 'staging' | 'dev')[] = ['prod', 'staging', 'dev']
|
||||||
|
|
||||||
|
const totalTenants = computed(() => props.tenants.length)
|
||||||
|
const rolloutCount = computed(() => Math.round((totalTenants.value * draft.value.pct) / 100))
|
||||||
|
|
||||||
|
const dirty = computed(() => {
|
||||||
|
if (!props.flag) return false
|
||||||
|
const f = props.flag
|
||||||
|
const d = draft.value
|
||||||
|
return (
|
||||||
|
d.description !== f.description ||
|
||||||
|
d.state !== f.state ||
|
||||||
|
d.pct !== f.pct ||
|
||||||
|
!arrEq(d.plans, f.scope.plans) ||
|
||||||
|
!arrEq(d.tenantSlugs, f.scope.tenantSlugs) ||
|
||||||
|
!arrEq(d.partnerSlugs, f.scope.partnerSlugs) ||
|
||||||
|
!arrEq(d.environments, f.scope.environments)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function arrEq(a: string[], b: string[]) {
|
||||||
|
if (a.length !== b.length) return false
|
||||||
|
const sa = [...a].sort()
|
||||||
|
const sb = [...b].sort()
|
||||||
|
return sa.every((v, i) => v === sb[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
const busy = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
function togglePlan(p: string) {
|
||||||
|
draft.value.plans = draft.value.plans.includes(p)
|
||||||
|
? draft.value.plans.filter((x) => x !== p)
|
||||||
|
: [...draft.value.plans, p]
|
||||||
|
}
|
||||||
|
function toggleEnv(e: 'prod' | 'staging' | 'dev') {
|
||||||
|
draft.value.environments = draft.value.environments.includes(e)
|
||||||
|
? draft.value.environments.filter((x) => x !== e)
|
||||||
|
: [...draft.value.environments, e]
|
||||||
|
}
|
||||||
|
function toggleTenant(slug: string) {
|
||||||
|
draft.value.tenantSlugs = draft.value.tenantSlugs.includes(slug)
|
||||||
|
? draft.value.tenantSlugs.filter((x) => x !== slug)
|
||||||
|
: [...draft.value.tenantSlugs, slug]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!props.flag || !dirty.value) return
|
||||||
|
busy.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const updated = await $fetch<Flag>(`/api/flags/${props.flag.key}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: {
|
||||||
|
description: draft.value.description,
|
||||||
|
state: draft.value.state,
|
||||||
|
pct: draft.value.pct,
|
||||||
|
scope: {
|
||||||
|
plans: draft.value.plans,
|
||||||
|
tenantSlugs: draft.value.tenantSlugs,
|
||||||
|
partnerSlugs: draft.value.partnerSlugs,
|
||||||
|
environments: draft.value.environments,
|
||||||
|
},
|
||||||
|
note: draft.value.note || undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
emit('saved', updated)
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as { data?: { data?: { message?: string }; message?: string } }
|
||||||
|
error.value = err.data?.data?.message || err.data?.message || 'Save failed'
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function killSwitch() {
|
||||||
|
if (!props.flag) return
|
||||||
|
busy.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const updated = await $fetch<Flag>(`/api/flags/${props.flag.key}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { state: 'off', pct: 0, note: 'kill-switch' },
|
||||||
|
})
|
||||||
|
emit('saved', updated)
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as { data?: { message?: string } }
|
||||||
|
error.value = err.data?.message || 'Kill-switch failed'
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(s: string) {
|
||||||
|
return new Date(s).toLocaleString('da-DK', { dateStyle: 'short', timeStyle: 'short' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="drawer-fade">
|
||||||
|
<div v-if="flag" class="backdrop" @click="emit('close')" />
|
||||||
|
</Transition>
|
||||||
|
<Transition name="drawer-slide">
|
||||||
|
<aside v-if="flag" class="drawer" role="dialog" :aria-label="flag.key" @click.stop>
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<Eyebrow>Feature flag</Eyebrow>
|
||||||
|
<h2><Mono>{{ flag.key }}</Mono></h2>
|
||||||
|
</div>
|
||||||
|
<button class="x" type="button" aria-label="Close" @click="emit('close')">
|
||||||
|
<UiIcon name="x" :size="13" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="body">
|
||||||
|
<section>
|
||||||
|
<label class="label">Description</label>
|
||||||
|
<input v-model="draft.description" type="text" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<label class="label">State</label>
|
||||||
|
<div class="seg four">
|
||||||
|
<button
|
||||||
|
v-for="s in STATES"
|
||||||
|
:key="s"
|
||||||
|
:class="{ on: draft.state === s }"
|
||||||
|
type="button"
|
||||||
|
@click="draft.state = s"
|
||||||
|
>{{ s }}</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="draft.state === 'rollout'">
|
||||||
|
<label class="label">Rollout percentage</label>
|
||||||
|
<div class="rollout-row">
|
||||||
|
<input v-model.number="draft.pct" type="range" min="0" max="100" />
|
||||||
|
<Mono class="pct">{{ draft.pct }}%</Mono>
|
||||||
|
</div>
|
||||||
|
<Mono dim>~{{ rolloutCount }} of {{ totalTenants }} tenants will see this flag enabled</Mono>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="draft.state === 'targeted' || draft.state === 'rollout'">
|
||||||
|
<label class="label">Plans</label>
|
||||||
|
<div class="chips">
|
||||||
|
<button
|
||||||
|
v-for="p in ALL_PLANS"
|
||||||
|
:key="p"
|
||||||
|
:class="['chip', { on: draft.plans.includes(p) }]"
|
||||||
|
type="button"
|
||||||
|
@click="togglePlan(p)"
|
||||||
|
>{{ p }}</button>
|
||||||
|
</div>
|
||||||
|
<Mono dim>empty = any plan</Mono>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="draft.state === 'targeted' || draft.state === 'rollout'">
|
||||||
|
<label class="label">Tenant allowlist</label>
|
||||||
|
<div v-if="tenants.length" class="chips wrap">
|
||||||
|
<button
|
||||||
|
v-for="t in tenants"
|
||||||
|
:key="t.slug"
|
||||||
|
:class="['chip', { on: draft.tenantSlugs.includes(t.slug) }]"
|
||||||
|
type="button"
|
||||||
|
:title="t.name"
|
||||||
|
@click="toggleTenant(t.slug)"
|
||||||
|
>{{ t.slug }}</button>
|
||||||
|
</div>
|
||||||
|
<Mono dim>empty = any tenant</Mono>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="draft.state === 'targeted' || draft.state === 'rollout'">
|
||||||
|
<label class="label">Environments</label>
|
||||||
|
<div class="chips">
|
||||||
|
<button
|
||||||
|
v-for="e in ALL_ENVS"
|
||||||
|
:key="e"
|
||||||
|
:class="['chip', { on: draft.environments.includes(e) }]"
|
||||||
|
type="button"
|
||||||
|
@click="toggleEnv(e)"
|
||||||
|
>{{ e }}</button>
|
||||||
|
</div>
|
||||||
|
<Mono dim>empty = any environment</Mono>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="dirty">
|
||||||
|
<label class="label">Change note (optional)</label>
|
||||||
|
<input v-model="draft.note" type="text" placeholder="why are you changing this?" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<label class="label">Change history</label>
|
||||||
|
<div class="history">
|
||||||
|
<div v-for="(h, i) in [...flag.history].reverse()" :key="i" class="hist-row">
|
||||||
|
<Mono dim>{{ fmtDate(h.at) }}</Mono>
|
||||||
|
<Mono>{{ h.byEmail || 'system' }}</Mono>
|
||||||
|
<span>{{ h.action }}</span>
|
||||||
|
<Mono v-if="h.note" dim class="hist-note">— {{ h.note }}</Mono>
|
||||||
|
</div>
|
||||||
|
<Mono v-if="!flag.history.length" dim>no history</Mono>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p v-if="error" class="err">{{ error }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<UiButton variant="danger" :disabled="busy || flag.state === 'off'" @click="killSwitch">
|
||||||
|
<template #leading><UiIcon name="x" :size="13" /></template>
|
||||||
|
Kill-switch
|
||||||
|
</UiButton>
|
||||||
|
<div class="spacer" />
|
||||||
|
<UiButton variant="ghost" @click="emit('close')">Cancel</UiButton>
|
||||||
|
<UiButton variant="primary" :disabled="!dirty || busy" @click="save">
|
||||||
|
{{ busy ? 'Saving…' : 'Save changes' }}
|
||||||
|
</UiButton>
|
||||||
|
</footer>
|
||||||
|
</aside>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.backdrop { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.45); z-index: 190; }
|
||||||
|
|
||||||
|
.drawer {
|
||||||
|
position: fixed; top: 0; right: 0; bottom: 0;
|
||||||
|
width: min(560px, 100vw);
|
||||||
|
background: var(--elevated);
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
box-shadow: -16px 0 48px rgba(0, 0, 0, 0.35);
|
||||||
|
z-index: 200;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
padding: 18px 22px 14px 22px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex; justify-content: space-between; align-items: flex-start;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
margin: 4px 0 0 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.x {
|
||||||
|
width: 28px; height: 28px;
|
||||||
|
border: 0; border-radius: 6px; background: transparent;
|
||||||
|
color: var(--text-mute); cursor: pointer;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.x:hover { background: var(--surface); color: var(--text); }
|
||||||
|
|
||||||
|
.body {
|
||||||
|
flex: 1; overflow-y: auto;
|
||||||
|
padding: 20px 22px;
|
||||||
|
display: flex; flex-direction: column; gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
section { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-mute);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='text'] {
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 12px;
|
||||||
|
background: var(--input-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
input[type='text']:focus { border-color: var(--border-hi); }
|
||||||
|
|
||||||
|
.seg {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 3px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 7px;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
.seg.four { grid-template-columns: repeat(4, minmax(72px, 1fr)); }
|
||||||
|
.seg button {
|
||||||
|
appearance: none;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.seg button:hover { color: var(--text); }
|
||||||
|
.seg button.on { background: var(--text); color: var(--bg); }
|
||||||
|
|
||||||
|
.rollout-row { display: flex; align-items: center; gap: 14px; }
|
||||||
|
.rollout-row input { flex: 1; accent-color: var(--accent); }
|
||||||
|
.pct { font-size: 14px; font-weight: 600; min-width: 50px; text-align: right; }
|
||||||
|
|
||||||
|
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.chips.wrap { max-height: 140px; overflow-y: auto; padding: 4px 0; }
|
||||||
|
.chip {
|
||||||
|
appearance: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.chip:hover { color: var(--text); border-color: var(--border-hi); }
|
||||||
|
.chip.on { background: var(--text); color: var(--bg); border-color: var(--text); }
|
||||||
|
|
||||||
|
.history {
|
||||||
|
display: flex; flex-direction: column; gap: 6px;
|
||||||
|
border-left: 2px solid var(--border);
|
||||||
|
padding-left: 12px;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.hist-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 110px 160px 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: baseline;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.hist-note { grid-column: 1 / -1; padding-left: 0; }
|
||||||
|
|
||||||
|
.err {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: rgba(240, 88, 88, 0.08);
|
||||||
|
border: 1px solid rgba(240, 88, 88, 0.24);
|
||||||
|
color: var(--bad);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
padding: 12px 18px;
|
||||||
|
display: flex; gap: 8px; align-items: center;
|
||||||
|
}
|
||||||
|
.spacer { flex: 1; }
|
||||||
|
|
||||||
|
.drawer-fade-enter-active, .drawer-fade-leave-active { transition: opacity 0.18s ease; }
|
||||||
|
.drawer-fade-enter-from, .drawer-fade-leave-to { opacity: 0; }
|
||||||
|
.drawer-slide-enter-active, .drawer-slide-leave-active { transition: transform 0.22s cubic-bezier(0.2, 0.7, 0.2, 1); }
|
||||||
|
.drawer-slide-enter-from, .drawer-slide-leave-to { transform: translateX(100%); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// "New flag" modal — opens from the page header. Only collects the absolute
|
||||||
|
// minimum (key + optional description); state/rollout/targeting are tuned in
|
||||||
|
// the FlagDetail side panel after creation, which is the same surface used
|
||||||
|
// for every subsequent edit.
|
||||||
|
|
||||||
|
import type { Flag } from '~/types/flag'
|
||||||
|
|
||||||
|
const props = defineProps<{ open: boolean }>()
|
||||||
|
const emit = defineEmits<{ close: []; created: [Flag] }>()
|
||||||
|
|
||||||
|
const key = ref('')
|
||||||
|
const description = ref('')
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const busy = ref(false)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
(v) => {
|
||||||
|
if (v) {
|
||||||
|
key.value = ''
|
||||||
|
description.value = ''
|
||||||
|
error.value = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && props.open) emit('close')
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
|
||||||
|
})
|
||||||
|
|
||||||
|
const valid = computed(() => /^[a-z][a-z0-9_]{1,62}[a-z0-9]$/.test(key.value.trim()))
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!valid.value) return
|
||||||
|
busy.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const created = await $fetch<Flag>('/api/flags', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { key: key.value.trim(), description: description.value.trim() },
|
||||||
|
})
|
||||||
|
emit('created', created)
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as { data?: { data?: { message?: string }; message?: string } }
|
||||||
|
error.value = err.data?.data?.message || err.data?.message || 'Create failed'
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="open" class="backdrop" @click="emit('close')">
|
||||||
|
<div class="modal" role="dialog" aria-label="New feature flag" @click.stop>
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<Eyebrow>Engineering</Eyebrow>
|
||||||
|
<h2>New feature flag</h2>
|
||||||
|
</div>
|
||||||
|
<button class="x" type="button" aria-label="Close" @click="emit('close')">
|
||||||
|
<UiIcon name="x" :size="12" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="body">
|
||||||
|
<section>
|
||||||
|
<label class="label">Key</label>
|
||||||
|
<input v-model="key" type="text" placeholder="e.g. new_inbox_view" />
|
||||||
|
<Mono dim>lowercase snake_case · starts with a letter · 3–64 chars</Mono>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<label class="label">Description (optional)</label>
|
||||||
|
<input v-model="description" type="text" placeholder="what does this flag gate?" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p v-if="error" class="err">{{ error }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<UiButton variant="ghost" @click="emit('close')">Cancel</UiButton>
|
||||||
|
<UiButton variant="primary" :disabled="!valid || busy" @click="submit">
|
||||||
|
{{ busy ? 'Creating…' : 'Create flag' }}
|
||||||
|
</UiButton>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.backdrop {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
z-index: 180;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
width: 100%; max-width: 460px;
|
||||||
|
background: var(--elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
padding: 16px 20px;
|
||||||
|
display: flex; justify-content: space-between; align-items: flex-start;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
margin: 4px 0 0 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
.x {
|
||||||
|
width: 26px; height: 26px;
|
||||||
|
border: 0; border-radius: 6px; background: transparent;
|
||||||
|
color: var(--text-mute); cursor: pointer;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.x:hover { background: var(--surface); color: var(--text); }
|
||||||
|
|
||||||
|
.body {
|
||||||
|
padding: 16px 20px;
|
||||||
|
display: flex; flex-direction: column; gap: 14px;
|
||||||
|
}
|
||||||
|
section { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-mute);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 12px;
|
||||||
|
background: var(--input-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
input:focus { border-color: var(--border-hi); }
|
||||||
|
|
||||||
|
.err {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: rgba(240, 88, 88, 0.08);
|
||||||
|
border: 1px solid rgba(240, 88, 88, 0.24);
|
||||||
|
color: var(--bad);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
padding: 12px 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -59,27 +59,9 @@ export const INCIDENT: ActiveIncident = {
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FlagState = 'on' | 'off' | 'rollout' | 'targeted'
|
// Feature flags moved to a real backend at /api/flags + see types/flag.ts.
|
||||||
export interface FeatureFlag {
|
// The seed in services/platform-api/src/seed/seed.service.ts creates the
|
||||||
key: string
|
// same 10 flags this fixture used to contain.
|
||||||
state: FlagState
|
|
||||||
pct: number
|
|
||||||
scope: string
|
|
||||||
modified: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FLAGS: FeatureFlag[] = [
|
|
||||||
{ key: 'jmap_native_v2', state: 'rollout', pct: 50, scope: 'Business+ · 38 tenants', modified: 'Anne · 2 d ago' },
|
|
||||||
{ key: 'oci_versioning', state: 'on', pct: 100, scope: 'all tenants', modified: 'Anne · 14 d ago' },
|
|
||||||
{ key: 'jitsi_recording_e2ee', state: 'targeted', pct: 0, scope: 'allowlist · 3 tenants', modified: 'Mikkel · 5 d ago' },
|
|
||||||
{ key: 'new_billing_engine', state: 'rollout', pct: 25, scope: '12 tenants', modified: 'Anne · today' },
|
|
||||||
{ key: 'gdpr_export_v2', state: 'off', pct: 0, scope: 'kill-switch', modified: 'Sofie · 21 d ago' },
|
|
||||||
{ key: 'whitelabel_cssprops', state: 'on', pct: 100, scope: 'partners', modified: 'Anne · 1 mo ago' },
|
|
||||||
{ key: 'audit_log_streaming', state: 'on', pct: 100, scope: 'Enterprise', modified: 'Mikkel · 8 d ago' },
|
|
||||||
{ key: 'zulip_topic_threading', state: 'rollout', pct: 75, scope: '63 tenants', modified: 'Sofie · 3 d ago' },
|
|
||||||
{ key: 'tos_2026_acceptance', state: 'on', pct: 100, scope: 'all tenants', modified: 'Anne · 6 d ago' },
|
|
||||||
{ key: 'beta_ai_summaries', state: 'off', pct: 0, scope: 'killed', modified: 'Anne · 1 mo ago' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export type AuditTone = 'info' | 'warn' | 'bad'
|
export type AuditTone = 'info' | 'warn' | 'bad'
|
||||||
export interface AuditEntry {
|
export interface AuditEntry {
|
||||||
|
|||||||
@@ -1,18 +1,63 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { FLAGS, type FeatureFlag } from '~/data/fixtures'
|
import type { Flag, FlagState } from '~/types/flag'
|
||||||
|
import type { Tenant } from '~/types/tenant'
|
||||||
|
|
||||||
function stateTone(f: FeatureFlag): 'ok' | 'neutral' | 'warn' | 'info' {
|
const { data: flags, refresh } = await useFetch<Flag[]>('/api/flags', {
|
||||||
switch (f.state) {
|
default: () => [],
|
||||||
|
})
|
||||||
|
// Tenants are needed by FlagDetail for the allowlist picker + the rollout
|
||||||
|
// preview ("~N of M tenants will see this"). Lazy because we only need them
|
||||||
|
// when the side panel opens.
|
||||||
|
const { data: tenants } = useLazyFetch<Tenant[]>('/api/tenants', {
|
||||||
|
default: () => [],
|
||||||
|
server: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const open = ref<Flag | null>(null)
|
||||||
|
const showNew = ref(false)
|
||||||
|
|
||||||
|
function stateTone(state: FlagState): 'ok' | 'neutral' | 'warn' | 'info' {
|
||||||
|
switch (state) {
|
||||||
case 'on': return 'ok'
|
case 'on': return 'ok'
|
||||||
case 'off': return 'neutral'
|
case 'off': return 'neutral'
|
||||||
case 'rollout': return 'warn'
|
case 'rollout': return 'warn'
|
||||||
case 'targeted': return 'info'
|
case 'targeted': return 'info'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function stateLabel(f: FeatureFlag) {
|
function stateLabel(f: Flag) {
|
||||||
if (f.state === 'rollout') return `${f.pct}% rollout`
|
if (f.state === 'rollout') return `${f.pct}% rollout`
|
||||||
return f.state
|
return f.state
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onSaved(updated: Flag) {
|
||||||
|
// Replace the row in-place so the list reflects the new state without a
|
||||||
|
// full refetch — but also refresh in the background to pick up any
|
||||||
|
// server-side derived fields (history entries, updatedAt).
|
||||||
|
flags.value = (flags.value ?? []).map((f) => (f.key === updated.key ? updated : f))
|
||||||
|
open.value = updated
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCreated(created: Flag) {
|
||||||
|
flags.value = [...(flags.value ?? []), created].sort((a, b) => a.key.localeCompare(b.key))
|
||||||
|
showNew.value = false
|
||||||
|
open.value = created
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(s: string) {
|
||||||
|
return new Date(s).toLocaleDateString('da-DK', { day: '2-digit', month: 'short' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeScope(f: Flag): string {
|
||||||
|
if (f.state === 'on') return 'all'
|
||||||
|
if (f.state === 'off') return '—'
|
||||||
|
const parts: string[] = []
|
||||||
|
if (f.scope.plans.length) parts.push(`plan: ${f.scope.plans.join(', ')}`)
|
||||||
|
if (f.scope.tenantSlugs.length) parts.push(`${f.scope.tenantSlugs.length} tenant${f.scope.tenantSlugs.length === 1 ? '' : 's'}`)
|
||||||
|
if (f.scope.partnerSlugs.length) parts.push(`${f.scope.partnerSlugs.length} partner${f.scope.partnerSlugs.length === 1 ? '' : 's'}`)
|
||||||
|
if (f.scope.environments.length) parts.push(`env: ${f.scope.environments.join('/')}`)
|
||||||
|
return parts.length ? parts.join(' · ') : 'no targeting'
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -20,10 +65,10 @@ function stateLabel(f: FeatureFlag) {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
eyebrow="Engineering"
|
eyebrow="Engineering"
|
||||||
title="Feature flags"
|
title="Feature flags"
|
||||||
subtitle="Toggle, target, and roll out platform features. Every change is logged."
|
:subtitle="`${flags.length} flags. Toggle, target, and roll out features without redeploying.`"
|
||||||
>
|
>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<UiButton variant="primary" disabled>
|
<UiButton variant="primary" @click="showNew = true">
|
||||||
<template #leading><UiIcon name="plus" :size="13" /></template>
|
<template #leading><UiIcon name="plus" :size="13" /></template>
|
||||||
New flag
|
New flag
|
||||||
</UiButton>
|
</UiButton>
|
||||||
@@ -40,19 +85,23 @@ function stateLabel(f: FeatureFlag) {
|
|||||||
<th>Rollout</th>
|
<th>Rollout</th>
|
||||||
<th>Scope</th>
|
<th>Scope</th>
|
||||||
<th>Last modified</th>
|
<th>Last modified</th>
|
||||||
|
<th class="r"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="f in FLAGS" :key="f.key">
|
<tr v-for="f in flags" :key="f.key" @click="open = f">
|
||||||
<td>
|
<td>
|
||||||
<div class="key">
|
<div class="key">
|
||||||
<span class="key-tile" :data-state="f.state">
|
<span class="key-tile" :data-state="f.state">
|
||||||
<UiIcon name="plug" :size="11" />
|
<UiIcon name="plug" :size="11" />
|
||||||
</span>
|
</span>
|
||||||
<Mono class="key-name">{{ f.key }}</Mono>
|
<div>
|
||||||
|
<Mono class="key-name">{{ f.key }}</Mono>
|
||||||
|
<div v-if="f.description" class="key-desc">{{ f.description }}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td><Badge :tone="stateTone(f)" dot>{{ stateLabel(f) }}</Badge></td>
|
<td><Badge :tone="stateTone(f.state)" dot>{{ stateLabel(f) }}</Badge></td>
|
||||||
<td>
|
<td>
|
||||||
<div v-if="f.state === 'rollout'" class="rollout">
|
<div v-if="f.state === 'rollout'" class="rollout">
|
||||||
<div class="bar"><div class="fill" :style="{ width: `${f.pct}%` }" /></div>
|
<div class="bar"><div class="fill" :style="{ width: `${f.pct}%` }" /></div>
|
||||||
@@ -60,15 +109,39 @@ function stateLabel(f: FeatureFlag) {
|
|||||||
</div>
|
</div>
|
||||||
<Mono v-else dim>—</Mono>
|
<Mono v-else dim>—</Mono>
|
||||||
</td>
|
</td>
|
||||||
<td><Mono dim>{{ f.scope }}</Mono></td>
|
<td>
|
||||||
<td><Mono dim>{{ f.modified }}</Mono></td>
|
<Mono dim>{{ describeScope(f) }}</Mono>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Mono dim>{{ fmtDate(f.updatedAt) }}</Mono>
|
||||||
|
</td>
|
||||||
|
<td class="r">
|
||||||
|
<UiIcon name="chevRight" :size="12" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!flags.length">
|
||||||
|
<td colspan="6" class="empty">
|
||||||
|
<Mono dim>// no flags yet</Mono>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Mono dim class="note">// mock fixtures — wire to a feature-flag service (Unleash / OpenFeature) in a follow-up</Mono>
|
<Mono dim class="note">
|
||||||
|
// backed by /api/flags · seed contains 10 flags mapping to real Dezky
|
||||||
|
concerns. Consumers (portal, platform-api) call POST /api/flags/evaluate
|
||||||
|
with a tenant slug to get current boolean values.
|
||||||
|
</Mono>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<FlagDetail
|
||||||
|
:flag="open"
|
||||||
|
:tenants="tenants ?? []"
|
||||||
|
@close="open = null"
|
||||||
|
@saved="onSaved"
|
||||||
|
/>
|
||||||
|
<NewFlagModal :open="showNew" @close="showNew = false" @created="onCreated" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -87,26 +160,32 @@ th {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
td { padding: 12px 20px; font-size: 12px; border-top: 1px solid var(--border); vertical-align: middle; }
|
th.r, td.r { text-align: right; }
|
||||||
|
td { padding: 12px 20px; font-size: 12px; border-top: 1px solid var(--border); vertical-align: middle; color: var(--text-mute); }
|
||||||
|
tbody tr { cursor: pointer; }
|
||||||
|
tbody tr:hover { background: var(--surface); }
|
||||||
|
|
||||||
.key { display: flex; align-items: center; gap: 10px; }
|
.key { display: flex; align-items: center; gap: 10px; }
|
||||||
.key-tile {
|
.key-tile {
|
||||||
width: 22px; height: 22px;
|
width: 24px; height: 24px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
color: var(--text-mute);
|
color: var(--text-mute);
|
||||||
}
|
}
|
||||||
.key-tile[data-state='on'] { background: rgba(31, 138, 91, 0.12); color: var(--ok); }
|
.key-tile[data-state='on'] { background: rgba(31, 138, 91, 0.12); color: var(--ok); }
|
||||||
.key-tile[data-state='off'] { background: rgba(128, 128, 128, 0.12); color: var(--text-mute); }
|
.key-tile[data-state='off'] { background: rgba(128, 128, 128, 0.12); color: var(--text-mute); }
|
||||||
.key-tile[data-state='rollout'] { background: rgba(232, 154, 31, 0.12); color: var(--warn); }
|
.key-tile[data-state='rollout'] { background: rgba(232, 154, 31, 0.12); color: var(--warn); }
|
||||||
.key-tile[data-state='targeted']{ background: rgba(42, 111, 219, 0.1); color: var(--info); }
|
.key-tile[data-state='targeted']{ background: rgba(42, 111, 219, 0.1); color: var(--info); }
|
||||||
.key-name { font-weight: 600; }
|
.key-name { font-weight: 600; color: var(--text); display: block; }
|
||||||
|
.key-desc { font-size: 11px; color: var(--text-mute); margin-top: 2px; }
|
||||||
|
|
||||||
.rollout { display: inline-flex; align-items: center; gap: 10px; width: 160px; }
|
.rollout { display: inline-flex; align-items: center; gap: 10px; width: 160px; }
|
||||||
.bar { flex: 1; height: 4px; background: var(--border); border-radius: 999px; overflow: hidden; }
|
.bar { flex: 1; height: 4px; background: var(--border); border-radius: 999px; overflow: hidden; }
|
||||||
.fill { height: 100%; background: var(--warn); }
|
.fill { height: 100%; background: var(--warn); }
|
||||||
|
|
||||||
|
.empty { padding: 32px 20px; text-align: center; }
|
||||||
.note { display: block; padding: 4px 4px 0 4px; }
|
.note { display: block; padding: 4px 4px 0 4px; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { platformApi } from '~~/server/utils/platform-api'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const key = getRouterParam(event, 'key')
|
||||||
|
await platformApi(event, `/flags/${key}`, { method: 'DELETE' })
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { platformApi } from '~~/server/utils/platform-api'
|
||||||
|
|
||||||
|
export default defineEventHandler((event) => {
|
||||||
|
const key = getRouterParam(event, 'key')
|
||||||
|
return platformApi(event, `/flags/${key}`)
|
||||||
|
})
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { platformApi } from '~~/server/utils/platform-api'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const key = getRouterParam(event, 'key')
|
||||||
|
const body = await readBody(event)
|
||||||
|
return platformApi(event, `/flags/${key}`, { method: 'PATCH', body })
|
||||||
|
})
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { platformApi } from '~~/server/utils/platform-api'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const body = await readBody(event)
|
||||||
|
return platformApi(event, '/flags/evaluate', { method: 'POST', body })
|
||||||
|
})
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { platformApi } from '~~/server/utils/platform-api'
|
||||||
|
|
||||||
|
export default defineEventHandler((event) => platformApi(event, '/flags'))
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { platformApi } from '~~/server/utils/platform-api'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const body = await readBody(event)
|
||||||
|
return platformApi(event, '/flags', { method: 'POST', body })
|
||||||
|
})
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// Shape returned by /api/flags — matches the Flag schema on platform-api.
|
||||||
|
|
||||||
|
export type FlagState = 'off' | 'targeted' | 'rollout' | 'on'
|
||||||
|
|
||||||
|
export interface FlagHistoryEntry {
|
||||||
|
at: string
|
||||||
|
byEmail?: string
|
||||||
|
action: string
|
||||||
|
note?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlagScope {
|
||||||
|
plans: string[]
|
||||||
|
tenantSlugs: string[]
|
||||||
|
partnerSlugs: string[]
|
||||||
|
environments: ('prod' | 'staging' | 'dev')[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Flag {
|
||||||
|
_id: string
|
||||||
|
key: string
|
||||||
|
description: string
|
||||||
|
state: FlagState
|
||||||
|
pct: number
|
||||||
|
scope: FlagScope
|
||||||
|
history: FlagHistoryEntry[]
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'
|
|||||||
import { ConfigModule } from '@nestjs/config'
|
import { ConfigModule } from '@nestjs/config'
|
||||||
import { MongooseModule } from '@nestjs/mongoose'
|
import { MongooseModule } from '@nestjs/mongoose'
|
||||||
import { AuthModule } from './auth/auth.module.js'
|
import { AuthModule } from './auth/auth.module.js'
|
||||||
|
import { FlagsModule } from './flags/flags.module.js'
|
||||||
import { HealthModule } from './health/health.module.js'
|
import { HealthModule } from './health/health.module.js'
|
||||||
import { PartnersModule } from './partners/partners.module.js'
|
import { PartnersModule } from './partners/partners.module.js'
|
||||||
import { SeedModule } from './seed/seed.module.js'
|
import { SeedModule } from './seed/seed.module.js'
|
||||||
@@ -21,6 +22,7 @@ import { UsersModule } from './users/users.module.js'
|
|||||||
PartnersModule,
|
PartnersModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
SubscriptionsModule,
|
SubscriptionsModule,
|
||||||
|
FlagsModule,
|
||||||
SeedModule,
|
SeedModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import {
|
||||||
|
ArrayUnique,
|
||||||
|
IsArray,
|
||||||
|
IsEnum,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Matches,
|
||||||
|
Max,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator'
|
||||||
|
import { Type } from 'class-transformer'
|
||||||
|
|
||||||
|
class FlagScopeDto {
|
||||||
|
@IsOptional() @IsArray() @ArrayUnique() @IsString({ each: true })
|
||||||
|
plans?: string[]
|
||||||
|
|
||||||
|
@IsOptional() @IsArray() @ArrayUnique() @IsString({ each: true })
|
||||||
|
tenantSlugs?: string[]
|
||||||
|
|
||||||
|
@IsOptional() @IsArray() @ArrayUnique() @IsString({ each: true })
|
||||||
|
partnerSlugs?: string[]
|
||||||
|
|
||||||
|
@IsOptional() @IsArray() @ArrayUnique()
|
||||||
|
@IsEnum(['prod', 'staging', 'dev'], { each: true })
|
||||||
|
environments?: ('prod' | 'staging' | 'dev')[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateFlagDto {
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^[a-z][a-z0-9_]{1,62}[a-z0-9]$/, {
|
||||||
|
message: 'key must be lowercase snake_case, 3-64 chars, starts with a letter',
|
||||||
|
})
|
||||||
|
key!: string
|
||||||
|
|
||||||
|
@IsOptional() @IsString() @MaxLength(280)
|
||||||
|
description?: string
|
||||||
|
|
||||||
|
@IsOptional() @IsEnum(['off', 'targeted', 'rollout', 'on'])
|
||||||
|
state?: 'off' | 'targeted' | 'rollout' | 'on'
|
||||||
|
|
||||||
|
@IsOptional() @IsInt() @Min(0) @Max(100)
|
||||||
|
pct?: number
|
||||||
|
|
||||||
|
@IsOptional() @ValidateNested() @Type(() => FlagScopeDto)
|
||||||
|
scope?: FlagScopeDto
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { IsOptional, IsString, MaxLength } from 'class-validator'
|
||||||
|
|
||||||
|
// Evaluation context. We currently only support tenant-level flags, so the
|
||||||
|
// only required input is the tenant slug; we hydrate plan + partnerSlug + env
|
||||||
|
// server-side from the tenant doc + runtime env.
|
||||||
|
export class EvaluateDto {
|
||||||
|
@IsString() @MaxLength(64)
|
||||||
|
tenantSlug!: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import {
|
||||||
|
ArrayUnique,
|
||||||
|
IsArray,
|
||||||
|
IsEnum,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Max,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator'
|
||||||
|
import { Type } from 'class-transformer'
|
||||||
|
|
||||||
|
class FlagScopeDto {
|
||||||
|
@IsOptional() @IsArray() @ArrayUnique() @IsString({ each: true })
|
||||||
|
plans?: string[]
|
||||||
|
|
||||||
|
@IsOptional() @IsArray() @ArrayUnique() @IsString({ each: true })
|
||||||
|
tenantSlugs?: string[]
|
||||||
|
|
||||||
|
@IsOptional() @IsArray() @ArrayUnique() @IsString({ each: true })
|
||||||
|
partnerSlugs?: string[]
|
||||||
|
|
||||||
|
@IsOptional() @IsArray() @ArrayUnique()
|
||||||
|
@IsEnum(['prod', 'staging', 'dev'], { each: true })
|
||||||
|
environments?: ('prod' | 'staging' | 'dev')[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// `key` is immutable after create — rename would silently invalidate every
|
||||||
|
// flag check baked into consumer code.
|
||||||
|
export class UpdateFlagDto {
|
||||||
|
@IsOptional() @IsString() @MaxLength(280)
|
||||||
|
description?: string
|
||||||
|
|
||||||
|
@IsOptional() @IsEnum(['off', 'targeted', 'rollout', 'on'])
|
||||||
|
state?: 'off' | 'targeted' | 'rollout' | 'on'
|
||||||
|
|
||||||
|
@IsOptional() @IsInt() @Min(0) @Max(100)
|
||||||
|
pct?: number
|
||||||
|
|
||||||
|
@IsOptional() @ValidateNested() @Type(() => FlagScopeDto)
|
||||||
|
scope?: FlagScopeDto
|
||||||
|
|
||||||
|
// Optional free-form note attached to the resulting history entry.
|
||||||
|
@IsOptional() @IsString() @MaxLength(280)
|
||||||
|
note?: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common'
|
||||||
|
import { CurrentUser } from '../auth/current-user.decorator.js'
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard.js'
|
||||||
|
import { OperatorGuard } from '../auth/operator.guard.js'
|
||||||
|
import type { AuthentikJwtPayload } from '../auth/jwt-payload.interface.js'
|
||||||
|
import { CreateFlagDto } from './dto/create-flag.dto.js'
|
||||||
|
import { EvaluateDto } from './dto/evaluate.dto.js'
|
||||||
|
import { UpdateFlagDto } from './dto/update-flag.dto.js'
|
||||||
|
import { FlagsService } from './flags.service.js'
|
||||||
|
|
||||||
|
// Read access is open to any authed token (eval works for any tenant member);
|
||||||
|
// mutations require an operator-scoped token + platformAdmin. Same shape as
|
||||||
|
// PartnersController so the auth model stays predictable.
|
||||||
|
@Controller('flags')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class FlagsController {
|
||||||
|
constructor(private readonly flags: FlagsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
findAll() {
|
||||||
|
return this.flags.findAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':key')
|
||||||
|
findOne(@Param('key') key: string) {
|
||||||
|
return this.flags.findOne(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('evaluate')
|
||||||
|
evaluate(@Body() dto: EvaluateDto) {
|
||||||
|
return this.flags.evaluateAll(dto.tenantSlug)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@UseGuards(OperatorGuard)
|
||||||
|
create(@Body() dto: CreateFlagDto, @CurrentUser() jwt: AuthentikJwtPayload) {
|
||||||
|
return this.flags.create(dto, { email: jwt.email })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':key')
|
||||||
|
@UseGuards(OperatorGuard)
|
||||||
|
update(
|
||||||
|
@Param('key') key: string,
|
||||||
|
@Body() dto: UpdateFlagDto,
|
||||||
|
@CurrentUser() jwt: AuthentikJwtPayload,
|
||||||
|
) {
|
||||||
|
return this.flags.update(key, dto, { email: jwt.email })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':key')
|
||||||
|
@HttpCode(204)
|
||||||
|
@UseGuards(OperatorGuard)
|
||||||
|
async remove(@Param('key') key: string) {
|
||||||
|
await this.flags.remove(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Module } from '@nestjs/common'
|
||||||
|
import { MongooseModule } from '@nestjs/mongoose'
|
||||||
|
import { AuthModule } from '../auth/auth.module.js'
|
||||||
|
import { Flag, FlagSchema } from '../schemas/flag.schema.js'
|
||||||
|
import { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
|
||||||
|
import { FlagsController } from './flags.controller.js'
|
||||||
|
import { FlagsService } from './flags.service.js'
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
AuthModule,
|
||||||
|
MongooseModule.forFeature([
|
||||||
|
{ name: Flag.name, schema: FlagSchema },
|
||||||
|
{ name: Tenant.name, schema: TenantSchema },
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
controllers: [FlagsController],
|
||||||
|
providers: [FlagsService],
|
||||||
|
exports: [FlagsService],
|
||||||
|
})
|
||||||
|
export class FlagsModule {}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import {
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common'
|
||||||
|
import { InjectModel } from '@nestjs/mongoose'
|
||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
import { Model } from 'mongoose'
|
||||||
|
import { Flag, FlagDocument } from '../schemas/flag.schema.js'
|
||||||
|
import { Tenant, TenantDocument } from '../schemas/tenant.schema.js'
|
||||||
|
import type { CreateFlagDto } from './dto/create-flag.dto.js'
|
||||||
|
import type { UpdateFlagDto } from './dto/update-flag.dto.js'
|
||||||
|
|
||||||
|
// Max number of history entries kept embedded on a flag doc. Older entries
|
||||||
|
// get dropped on the next change. If we want unbounded audit, route it to a
|
||||||
|
// separate audit collection (see NEXT-STEPS.md follow-up).
|
||||||
|
const HISTORY_LIMIT = 20
|
||||||
|
|
||||||
|
export interface EvalContext {
|
||||||
|
tenantId: string
|
||||||
|
tenantSlug: string
|
||||||
|
plan: string
|
||||||
|
partnerSlug?: string
|
||||||
|
env: 'prod' | 'staging' | 'dev'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActorRef {
|
||||||
|
userId?: string
|
||||||
|
email?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FlagsService {
|
||||||
|
private readonly logger = new Logger(FlagsService.name)
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectModel(Flag.name) private readonly flagModel: Model<FlagDocument>,
|
||||||
|
@InjectModel(Tenant.name) private readonly tenantModel: Model<TenantDocument>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async create(dto: CreateFlagDto, actor?: ActorRef): Promise<FlagDocument> {
|
||||||
|
const exists = await this.flagModel.exists({ key: dto.key })
|
||||||
|
if (exists) throw new ConflictException(`Flag "${dto.key}" already exists`)
|
||||||
|
const doc = await this.flagModel.create({
|
||||||
|
key: dto.key,
|
||||||
|
description: dto.description ?? '',
|
||||||
|
state: dto.state ?? 'off',
|
||||||
|
pct: dto.pct ?? 0,
|
||||||
|
scope: dto.scope ?? { plans: [], tenantSlugs: [], partnerSlugs: [], environments: [] },
|
||||||
|
history: [
|
||||||
|
{
|
||||||
|
at: new Date(),
|
||||||
|
byEmail: actor?.email,
|
||||||
|
action: `created · state=${dto.state ?? 'off'}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
return doc
|
||||||
|
}
|
||||||
|
|
||||||
|
findAll(): Promise<FlagDocument[]> {
|
||||||
|
return this.flagModel.find().sort({ key: 1 }).exec()
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(key: string): Promise<FlagDocument> {
|
||||||
|
const flag = await this.flagModel.findOne({ key }).exec()
|
||||||
|
if (!flag) throw new NotFoundException(`Flag "${key}" not found`)
|
||||||
|
return flag
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(key: string, dto: UpdateFlagDto, actor?: ActorRef): Promise<FlagDocument> {
|
||||||
|
const flag = await this.findOne(key)
|
||||||
|
|
||||||
|
const actions: string[] = []
|
||||||
|
if (dto.description !== undefined && dto.description !== flag.description) {
|
||||||
|
actions.push('description updated')
|
||||||
|
flag.description = dto.description
|
||||||
|
}
|
||||||
|
if (dto.state !== undefined && dto.state !== flag.state) {
|
||||||
|
actions.push(`state: ${flag.state} → ${dto.state}`)
|
||||||
|
flag.state = dto.state
|
||||||
|
}
|
||||||
|
if (dto.pct !== undefined && dto.pct !== flag.pct) {
|
||||||
|
actions.push(`pct: ${flag.pct}% → ${dto.pct}%`)
|
||||||
|
flag.pct = dto.pct
|
||||||
|
}
|
||||||
|
if (dto.scope !== undefined) {
|
||||||
|
// Replace the scope wholesale (DTO supplies the new shape).
|
||||||
|
flag.scope = {
|
||||||
|
plans: dto.scope.plans ?? flag.scope.plans,
|
||||||
|
tenantSlugs: dto.scope.tenantSlugs ?? flag.scope.tenantSlugs,
|
||||||
|
partnerSlugs: dto.scope.partnerSlugs ?? flag.scope.partnerSlugs,
|
||||||
|
environments: dto.scope.environments ?? flag.scope.environments,
|
||||||
|
}
|
||||||
|
actions.push('scope updated')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actions.length === 0) return flag // no-op update; don't pollute history
|
||||||
|
|
||||||
|
flag.history.push({
|
||||||
|
at: new Date(),
|
||||||
|
byEmail: actor?.email,
|
||||||
|
action: actions.join(' · '),
|
||||||
|
note: dto.note,
|
||||||
|
})
|
||||||
|
if (flag.history.length > HISTORY_LIMIT) {
|
||||||
|
flag.history.splice(0, flag.history.length - HISTORY_LIMIT)
|
||||||
|
}
|
||||||
|
|
||||||
|
await flag.save()
|
||||||
|
return flag
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(key: string): Promise<void> {
|
||||||
|
const result = await this.flagModel.deleteOne({ key }).exec()
|
||||||
|
if (result.deletedCount === 0) throw new NotFoundException(`Flag "${key}" not found`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Evaluation ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async evaluateAll(tenantSlug: string): Promise<Record<string, boolean>> {
|
||||||
|
const tenant = await this.tenantModel.findOne({ slug: tenantSlug }).exec()
|
||||||
|
if (!tenant) throw new NotFoundException(`Tenant "${tenantSlug}" not found`)
|
||||||
|
|
||||||
|
const env = (process.env.DEZKY_ENV ?? 'dev') as 'prod' | 'staging' | 'dev'
|
||||||
|
|
||||||
|
const ctx: EvalContext = {
|
||||||
|
tenantId: String(tenant._id),
|
||||||
|
tenantSlug: tenant.slug,
|
||||||
|
plan: tenant.plan,
|
||||||
|
// partnerId is on Tenant.partnerId; we'd need a lookup for the slug.
|
||||||
|
// Leave as undefined for now — the eval still works (partnerSlugs scope
|
||||||
|
// becomes "no partner" matcher). Hydrate when partner-targeted flags
|
||||||
|
// actually exist.
|
||||||
|
partnerSlug: undefined,
|
||||||
|
env,
|
||||||
|
}
|
||||||
|
|
||||||
|
const flags = await this.flagModel.find().exec()
|
||||||
|
const out: Record<string, boolean> = {}
|
||||||
|
for (const flag of flags) {
|
||||||
|
out[flag.key] = this.evaluateOne(flag, ctx)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
evaluateOne(flag: FlagDocument, ctx: EvalContext): boolean {
|
||||||
|
if (flag.state === 'off') return false
|
||||||
|
if (flag.state === 'on') return true
|
||||||
|
|
||||||
|
// `targeted` is an explicit allowlist. Empty scope = nobody is on the list
|
||||||
|
// yet — evaluating to false is the defensive read. If you wanted "everyone",
|
||||||
|
// you'd flip the state to `on`.
|
||||||
|
if (flag.state === 'targeted') {
|
||||||
|
if (!hasAnyScope(flag.scope)) return false
|
||||||
|
return matchesScope(flag.scope, ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// `rollout` filters by scope (empty axes = no restriction on that axis) and
|
||||||
|
// then by deterministic hash bucket within the matching set.
|
||||||
|
if (flag.state === 'rollout') {
|
||||||
|
if (!matchesScope(flag.scope, ctx)) return false
|
||||||
|
return inRolloutBucket(ctx.tenantId, flag.key, flag.pct)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pure helpers (exported for tests) ───────────────────────────────────────
|
||||||
|
|
||||||
|
export function hasAnyScope(scope: {
|
||||||
|
plans: string[]
|
||||||
|
tenantSlugs: string[]
|
||||||
|
partnerSlugs: string[]
|
||||||
|
environments: string[]
|
||||||
|
}): boolean {
|
||||||
|
return (
|
||||||
|
scope.plans.length > 0 ||
|
||||||
|
scope.tenantSlugs.length > 0 ||
|
||||||
|
scope.partnerSlugs.length > 0 ||
|
||||||
|
scope.environments.length > 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchesScope(
|
||||||
|
scope: {
|
||||||
|
plans: string[]
|
||||||
|
tenantSlugs: string[]
|
||||||
|
partnerSlugs: string[]
|
||||||
|
environments: string[]
|
||||||
|
},
|
||||||
|
ctx: EvalContext,
|
||||||
|
): boolean {
|
||||||
|
if (scope.environments.length && !scope.environments.includes(ctx.env)) return false
|
||||||
|
if (scope.plans.length && !scope.plans.includes(ctx.plan)) return false
|
||||||
|
if (scope.tenantSlugs.length && !scope.tenantSlugs.includes(ctx.tenantSlug)) return false
|
||||||
|
if (scope.partnerSlugs.length) {
|
||||||
|
if (!ctx.partnerSlug) return false
|
||||||
|
if (!scope.partnerSlugs.includes(ctx.partnerSlug)) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deterministic rollout assignment: sha256(`${tenantId}:${flagKey}`) → 0–99
|
||||||
|
// bucket. A given tenant either is or isn't in the rollout for a given flag.
|
||||||
|
// Bumping pct only flips the new slice, never moves existing in/out tenants.
|
||||||
|
export function inRolloutBucket(tenantId: string, flagKey: string, pct: number): boolean {
|
||||||
|
if (pct >= 100) return true
|
||||||
|
if (pct <= 0) return false
|
||||||
|
const hash = createHash('sha256').update(`${tenantId}:${flagKey}`).digest()
|
||||||
|
const bucket = hash.readUInt16BE(0) % 100
|
||||||
|
return bucket < pct
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
|
||||||
|
import { HydratedDocument, Types } from 'mongoose'
|
||||||
|
|
||||||
|
export type FlagDocument = HydratedDocument<Flag>
|
||||||
|
|
||||||
|
export type FlagState = 'off' | 'targeted' | 'rollout' | 'on'
|
||||||
|
|
||||||
|
// Embedded change log on the flag itself. Capped to the last 20 entries via a
|
||||||
|
// guard in FlagsService — keeps the doc small and the side-panel render fast.
|
||||||
|
export interface FlagHistoryEntry {
|
||||||
|
at: Date
|
||||||
|
byUserId?: Types.ObjectId
|
||||||
|
byEmail?: string
|
||||||
|
action: string // e.g. 'created', 'state: off → rollout', 'pct: 25 → 50'
|
||||||
|
note?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
@Schema({ collection: 'flags', timestamps: true })
|
||||||
|
export class Flag {
|
||||||
|
// snake_case identifier used in code paths. Same regex as TenantPlan/slug-ish
|
||||||
|
// — keeps it greppable and unambiguous in audit messages.
|
||||||
|
@Prop({ required: true, unique: true, index: true, lowercase: true, trim: true })
|
||||||
|
key!: string
|
||||||
|
|
||||||
|
// Human label shown in the UI. Optional but recommended.
|
||||||
|
@Prop({ default: '' })
|
||||||
|
description!: string
|
||||||
|
|
||||||
|
@Prop({ enum: ['off', 'targeted', 'rollout', 'on'], default: 'off', index: true })
|
||||||
|
state!: FlagState
|
||||||
|
|
||||||
|
// Only consulted when state === 'rollout'. Hash-based assignment to a bucket
|
||||||
|
// (see FlagsService.evaluate) means a given tenant either is or isn't in the
|
||||||
|
// rollout — flipping the pct only changes the new slice, not existing ones.
|
||||||
|
@Prop({ default: 0, min: 0, max: 100 })
|
||||||
|
pct!: number
|
||||||
|
|
||||||
|
// Targeting axes. All optional; empty/missing = "no restriction on this axis".
|
||||||
|
// For state === 'targeted' / 'rollout', a tenant must match every non-empty
|
||||||
|
// axis to be eligible (intersection, not union).
|
||||||
|
@Prop({
|
||||||
|
type: {
|
||||||
|
plans: { type: [String], default: [] },
|
||||||
|
tenantSlugs: { type: [String], default: [] },
|
||||||
|
partnerSlugs: { type: [String], default: [] },
|
||||||
|
environments: { type: [String], default: [] },
|
||||||
|
},
|
||||||
|
default: () => ({ plans: [], tenantSlugs: [], partnerSlugs: [], environments: [] }),
|
||||||
|
})
|
||||||
|
scope!: {
|
||||||
|
plans: string[]
|
||||||
|
tenantSlugs: string[]
|
||||||
|
partnerSlugs: string[]
|
||||||
|
environments: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
@Prop({ type: Types.ObjectId, ref: 'User' })
|
||||||
|
createdBy?: Types.ObjectId
|
||||||
|
|
||||||
|
// Capped at last 20 in FlagsService.recordHistory.
|
||||||
|
@Prop({
|
||||||
|
type: [
|
||||||
|
{
|
||||||
|
at: { type: Date, default: () => new Date() },
|
||||||
|
byUserId: { type: Types.ObjectId, ref: 'User' },
|
||||||
|
byEmail: String,
|
||||||
|
action: { type: String, required: true },
|
||||||
|
note: String,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
default: [],
|
||||||
|
})
|
||||||
|
history!: FlagHistoryEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FlagSchema = SchemaFactory.createForClass(Flag)
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common'
|
import { Module } from '@nestjs/common'
|
||||||
import { MongooseModule } from '@nestjs/mongoose'
|
import { MongooseModule } from '@nestjs/mongoose'
|
||||||
|
import { Flag, FlagSchema } from '../schemas/flag.schema.js'
|
||||||
import { Subscription, SubscriptionSchema } from '../schemas/subscription.schema.js'
|
import { Subscription, SubscriptionSchema } from '../schemas/subscription.schema.js'
|
||||||
import { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
|
import { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
|
||||||
import { User, UserSchema } from '../schemas/user.schema.js'
|
import { User, UserSchema } from '../schemas/user.schema.js'
|
||||||
@@ -11,6 +12,7 @@ import { SeedService } from './seed.service.js'
|
|||||||
{ name: Tenant.name, schema: TenantSchema },
|
{ name: Tenant.name, schema: TenantSchema },
|
||||||
{ name: User.name, schema: UserSchema },
|
{ name: User.name, schema: UserSchema },
|
||||||
{ name: Subscription.name, schema: SubscriptionSchema },
|
{ name: Subscription.name, schema: SubscriptionSchema },
|
||||||
|
{ name: Flag.name, schema: FlagSchema },
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
providers: [SeedService],
|
providers: [SeedService],
|
||||||
|
|||||||
@@ -2,10 +2,41 @@ import { Injectable, Logger, type OnApplicationBootstrap } from '@nestjs/common'
|
|||||||
import { ConfigService } from '@nestjs/config'
|
import { ConfigService } from '@nestjs/config'
|
||||||
import { InjectModel } from '@nestjs/mongoose'
|
import { InjectModel } from '@nestjs/mongoose'
|
||||||
import { Model } from 'mongoose'
|
import { Model } from 'mongoose'
|
||||||
|
import { Flag, FlagDocument, type FlagState } from '../schemas/flag.schema.js'
|
||||||
import { Subscription, SubscriptionDocument } from '../schemas/subscription.schema.js'
|
import { Subscription, SubscriptionDocument } from '../schemas/subscription.schema.js'
|
||||||
import { Tenant, TenantDocument } from '../schemas/tenant.schema.js'
|
import { Tenant, TenantDocument } from '../schemas/tenant.schema.js'
|
||||||
import { User, UserDocument } from '../schemas/user.schema.js'
|
import { User, UserDocument } from '../schemas/user.schema.js'
|
||||||
|
|
||||||
|
interface SeedFlag {
|
||||||
|
key: string
|
||||||
|
description: string
|
||||||
|
state: FlagState
|
||||||
|
pct?: number
|
||||||
|
scope?: {
|
||||||
|
plans?: string[]
|
||||||
|
tenantSlugs?: string[]
|
||||||
|
partnerSlugs?: string[]
|
||||||
|
environments?: ('prod' | 'staging' | 'dev')[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial set of flags. Keys + states map to real Dezky concerns documented
|
||||||
|
// in the operator design — flags here become real switches the operator
|
||||||
|
// can flip once the gated features land. Seeded with $setOnInsert so an
|
||||||
|
// operator editing them later doesn't get clobbered on restart.
|
||||||
|
const FLAG_SEEDS: SeedFlag[] = [
|
||||||
|
{ key: 'jmap_native_v2', state: 'off', description: 'Native JMAP client (replacing the HTTP-stub bridge)', scope: { plans: ['pro', 'enterprise'] } },
|
||||||
|
{ key: 'oci_versioning', state: 'on', description: 'OCIS file versioning enabled' },
|
||||||
|
{ key: 'jitsi_recording_e2ee', state: 'targeted', description: 'E2EE recording in Jitsi (pilot)' },
|
||||||
|
{ key: 'new_billing_engine', state: 'off', description: 'Stripe-backed billing engine — replacing manual invoicing' },
|
||||||
|
{ key: 'gdpr_export_v2', state: 'off', description: 'Self-serve GDPR data export — kept off until reviewed' },
|
||||||
|
{ key: 'whitelabel_cssprops', state: 'on', description: 'Per-partner whitelabel CSS variables' },
|
||||||
|
{ key: 'audit_log_streaming', state: 'off', description: 'Real-time audit log streaming to the operator UI', scope: { plans: ['enterprise'] } },
|
||||||
|
{ key: 'zulip_topic_threading', state: 'off', description: 'Zulip topic threading UI (post deploy)' },
|
||||||
|
{ key: 'tos_2026_acceptance', state: 'on', description: 'Force users to accept the v2026 Terms of Service' },
|
||||||
|
{ key: 'beta_ai_summaries', state: 'off', description: 'AI summaries for inbox + files — killed pending review' },
|
||||||
|
]
|
||||||
|
|
||||||
// Idempotent seed: a default 'dezky' tenant + the akadmin user, so the portal can
|
// Idempotent seed: a default 'dezky' tenant + the akadmin user, so the portal can
|
||||||
// query non-empty results immediately. Real user records are bootstrapped via
|
// query non-empty results immediately. Real user records are bootstrapped via
|
||||||
// UsersController.me() on each user's first authenticated request.
|
// UsersController.me() on each user's first authenticated request.
|
||||||
@@ -17,6 +48,7 @@ export class SeedService implements OnApplicationBootstrap {
|
|||||||
@InjectModel(Tenant.name) private readonly tenantModel: Model<TenantDocument>,
|
@InjectModel(Tenant.name) private readonly tenantModel: Model<TenantDocument>,
|
||||||
@InjectModel(User.name) private readonly userModel: Model<UserDocument>,
|
@InjectModel(User.name) private readonly userModel: Model<UserDocument>,
|
||||||
@InjectModel(Subscription.name) private readonly subModel: Model<SubscriptionDocument>,
|
@InjectModel(Subscription.name) private readonly subModel: Model<SubscriptionDocument>,
|
||||||
|
@InjectModel(Flag.name) private readonly flagModel: Model<FlagDocument>,
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -60,5 +92,44 @@ export class SeedService implements OnApplicationBootstrap {
|
|||||||
this.logger.log(`Subscription ready for ${tenant.slug}`)
|
this.logger.log(`Subscription ready for ${tenant.slug}`)
|
||||||
|
|
||||||
// No user seeded here — UsersController.me() upserts akadmin on first call.
|
// No user seeded here — UsersController.me() upserts akadmin on first call.
|
||||||
|
|
||||||
|
// Feature flags. Seeded via $setOnInsert so an operator who later edits a
|
||||||
|
// flag's state through the UI doesn't get their change reverted on the
|
||||||
|
// next bootstrap.
|
||||||
|
let createdFlags = 0
|
||||||
|
for (const seed of FLAG_SEEDS) {
|
||||||
|
const res = await this.flagModel
|
||||||
|
.findOneAndUpdate(
|
||||||
|
{ key: seed.key },
|
||||||
|
{
|
||||||
|
$setOnInsert: {
|
||||||
|
key: seed.key,
|
||||||
|
description: seed.description,
|
||||||
|
state: seed.state,
|
||||||
|
pct: seed.pct ?? 0,
|
||||||
|
scope: {
|
||||||
|
plans: seed.scope?.plans ?? [],
|
||||||
|
tenantSlugs: seed.scope?.tenantSlugs ?? [],
|
||||||
|
partnerSlugs: seed.scope?.partnerSlugs ?? [],
|
||||||
|
environments: seed.scope?.environments ?? [],
|
||||||
|
},
|
||||||
|
history: [{ at: new Date(), action: `seeded · state=${seed.state}` }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ upsert: true, new: true, rawResult: true },
|
||||||
|
)
|
||||||
|
.exec()
|
||||||
|
// Mongoose returns the raw mongo result; lastErrorObject.updatedExisting
|
||||||
|
// is false when it just inserted.
|
||||||
|
if (
|
||||||
|
(res as unknown as { lastErrorObject?: { updatedExisting?: boolean } })
|
||||||
|
.lastErrorObject?.updatedExisting === false
|
||||||
|
) {
|
||||||
|
createdFlags++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (createdFlags > 0) {
|
||||||
|
this.logger.log(`Seeded ${createdFlags} new flag(s) (of ${FLAG_SEEDS.length})`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user