868a305539
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.
77 lines
2.5 KiB
TypeScript
77 lines
2.5 KiB
TypeScript
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)
|