| /** | |
| * Feature flag registry β single source of truth for public-settings-driven | |
| * feature switches used by the sidebar, routes, and views. | |
| * | |
| * ## Why this module exists | |
| * | |
| * `public settings` reach the frontend through two channels: | |
| * | |
| * 1. **SSR injection** β the backend embeds `window.__APP_CONFIG__` into the | |
| * HTML. `main.ts` calls `appStore.initFromInjectedConfig()` synchronously | |
| * before Vue mounts, so `cachedPublicSettings` is populated on first | |
| * render. | |
| * 2. **Async API** β `App.vue` awaits `appStore.fetchPublicSettings()` on | |
| * mount as a fallback (used when injection is missing or stale). | |
| * | |
| * If the SSR injection struct forgets to include a feature flag field β the | |
| * exact bug that hid the "ε―η¨ζΈ ι" menu after every refresh β the frontend | |
| * reads `undefined` until the async call resolves. An opt-in flag written as | |
| * `settings?.xxx_enabled === true` then evaluates to `false` and the menu | |
| * disappears. An opt-out flag written as `settings?.xxx_enabled !== false` | |
| * evaluates to `true` (menu stays) but will flicker off if the backend sends | |
| * `false`. | |
| * | |
| * This module hides that `undefined` handling behind two explicit modes. | |
| * | |
| * ## Modes | |
| * | |
| * - **`opt-out`** (default enabled) β menu visible when settings unloaded, | |
| * hidden only when the backend explicitly sends `false`. Use for features | |
| * that ship enabled by default (Channel Monitor, Payment). | |
| * - **`opt-in`** (default disabled) β menu hidden when settings unloaded, | |
| * visible only when the backend explicitly sends `true`. Use for features | |
| * that ship disabled (Available Channels). | |
| * | |
| * For `opt-in` flags to render immediately on refresh, the backend **must** | |
| * inject the field through `PublicSettingsInjectionPayload`. A drift test in | |
| * `backend/internal/handler/dto/public_settings_injection_schema_test.go` | |
| * catches omissions. | |
| * | |
| * ## Adding a new flag | |
| * | |
| * 1. Backend `service/domain_constants.go` β `SettingKey<Name>Enabled` | |
| * 2. Backend `service/settings_view.go` β `PublicSettings` + `SystemSettings` | |
| * 3. Backend `service/setting_service.go` β `GetPublicSettings` / `UpdateSettings` / | |
| * `GetAllSettings` / `InitDefaultSettings` / | |
| * **`PublicSettingsInjectionPayload`** | |
| * (the drift test enforces this) | |
| * 4. Backend `handler/dto/settings.go` β `PublicSettings` + `SystemSettings` | |
| * 5. Backend `handler/setting_handler.go` β handler response | |
| * 6. Backend `handler/admin/setting_handler.go` β update request + audit diff | |
| * 7. Frontend `types/index.ts` β `PublicSettings` typings | |
| * 8. Frontend `api/admin/settings.ts` β admin DTO typings | |
| * 9. **Frontend `utils/featureFlags.ts` (this file)** β register via `defineFlag` | |
| * 10. Frontend `views/admin/SettingsView.vue` β Toggle UI + form defaults + save payload | |
| * 11. Frontend `components/layout/AppSidebar.vue` β attach via `makeSidebarFlag` | |
| * | |
| * ## Usage | |
| * | |
| * ```ts | |
| * import { FeatureFlags, makeSidebarFlag } from '@/utils/featureFlags' | |
| * | |
| * const flagAvailableChannels = makeSidebarFlag(FeatureFlags.availableChannels) | |
| * // ... | |
| * { path: '/available-channels', label: ..., featureFlag: flagAvailableChannels } | |
| * ``` | |
| * | |
| * `isFeatureFlagEnabled(flag)` returns the resolved boolean (`true` = show). | |
| * `makeSidebarFlag(flag)` returns a `() => boolean | undefined` compatible with | |
| * `AppSidebar.NavItem.featureFlag`, where `false` hides the menu entry. | |
| */ | |
| import { useAppStore } from '@/stores/app' | |
| import type { PublicSettings } from '@/types' | |
| export type FeatureFlagMode = 'opt-in' | 'opt-out' | |
| export interface FeatureFlagDefinition { | |
| /** Public-settings key used for lookup. */ | |
| readonly key: keyof PublicSettings | |
| /** Resolution mode when the key is missing/undefined. */ | |
| readonly mode: FeatureFlagMode | |
| /** Short human label for logs and debug tooling. */ | |
| readonly label: string | |
| } | |
| function defineFlag<K extends keyof PublicSettings>( | |
| def: { key: K; mode: FeatureFlagMode; label: string }, | |
| ): FeatureFlagDefinition { | |
| return def | |
| } | |
| /** | |
| * Registered feature flags. Add a new entry here when introducing a new | |
| * public-settings-driven switch; see the "Adding a new flag" checklist above. | |
| */ | |
| export const FeatureFlags = { | |
| channelMonitor: defineFlag({ | |
| key: 'channel_monitor_enabled', | |
| mode: 'opt-out', | |
| label: 'Channel Monitor', | |
| }), | |
| availableChannels: defineFlag({ | |
| key: 'available_channels_enabled', | |
| mode: 'opt-in', | |
| label: 'Available Channels', | |
| }), | |
| payment: defineFlag({ | |
| key: 'payment_enabled', | |
| mode: 'opt-out', | |
| label: 'Payment', | |
| }), | |
| riskControl: defineFlag({ | |
| key: 'risk_control_enabled', | |
| mode: 'opt-in', | |
| label: 'Risk Control', | |
| }), | |
| affiliate: defineFlag({ | |
| key: 'affiliate_enabled', | |
| mode: 'opt-in', | |
| label: 'Affiliate', | |
| }), | |
| } as const | |
| export type RegisteredFeatureFlag = keyof typeof FeatureFlags | |
| /** | |
| * Read the current value of a flag, honoring the mode's fallback. | |
| * `true` β the feature is enabled (menu/route should render). | |
| * `false` β the feature is disabled (menu/route should hide). | |
| */ | |
| export function isFeatureFlagEnabled(flag: FeatureFlagDefinition): boolean { | |
| const appStore = useAppStore() | |
| const raw = appStore.cachedPublicSettings?.[flag.key] as | |
| | boolean | |
| | undefined | |
| if (typeof raw === 'boolean') return raw | |
| // Settings not yet loaded β fall back to the flag's declared mode: | |
| // opt-out β visible by default, opt-in β hidden by default. | |
| return flag.mode === 'opt-out' | |
| } | |
| /** | |
| * Sidebar NavItem.featureFlag accepts a getter that returns | |
| * `false` to hide. Keeping the same contract lets callers swap in | |
| * registry-backed flags without changing AppSidebar's filter logic. | |
| */ | |
| export function makeSidebarFlag(flag: FeatureFlagDefinition): () => boolean { | |
| return () => isFeatureFlagEnabled(flag) | |
| } | |