From 67a3aa4b0f373154fad45730e2c8331a56deb592 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Thu, 12 Feb 2026 14:43:11 +0100 Subject: [PATCH] feat(service): add feature flags service layer Implement service for managing feature flags and access control with methods for checking access, creating/enabling flags, and managing whitelisted users/guilds/roles. --- .../feature-flags/feature-flags.service.ts | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 shared/modules/feature-flags/feature-flags.service.ts diff --git a/shared/modules/feature-flags/feature-flags.service.ts b/shared/modules/feature-flags/feature-flags.service.ts new file mode 100644 index 0000000..77ace39 --- /dev/null +++ b/shared/modules/feature-flags/feature-flags.service.ts @@ -0,0 +1,138 @@ +import { eq, or, and, inArray } from "drizzle-orm"; +import { featureFlags, featureFlagAccess } from "@db/schema"; +import { DrizzleClient } from "@shared/db/DrizzleClient"; +import { UserError } from "@shared/lib/errors"; + +export interface FeatureFlagContext { + guildId: string; + userId: string; + memberRoles: string[]; +} + +export const featureFlagsService = { + isFlagEnabled: async (flagName: string): Promise => { + const flag = await DrizzleClient.query.featureFlags.findFirst({ + where: eq(featureFlags.name, flagName), + }); + return flag?.enabled ?? false; + }, + + hasAccess: async ( + flagName: string, + context: FeatureFlagContext + ): Promise => { + const flag = await DrizzleClient.query.featureFlags.findFirst({ + where: eq(featureFlags.name, flagName), + }); + if (!flag || !flag.enabled) return false; + + const access = await DrizzleClient.query.featureFlagAccess.findFirst({ + where: and( + eq(featureFlagAccess.flagId, flag.id), + or( + eq(featureFlagAccess.guildId, BigInt(context.guildId)), + eq(featureFlagAccess.userId, BigInt(context.userId)) + ) + ), + }); + + if (access) return true; + + if (context.memberRoles.length > 0) { + const roleAccess = await DrizzleClient.query.featureFlagAccess.findFirst({ + where: and( + eq(featureFlagAccess.flagId, flag.id), + inArray(featureFlagAccess.roleId, context.memberRoles.map(r => BigInt(r))) + ), + }); + return !!roleAccess; + } + + return false; + }, + + createFlag: async (name: string, description?: string) => { + const [flag] = await DrizzleClient.insert(featureFlags).values({ + name, + description, + enabled: false, + }).returning(); + return flag; + }, + + setFlagEnabled: async (name: string, enabled: boolean) => { + const [flag] = await DrizzleClient.update(featureFlags) + .set({ enabled, updatedAt: new Date() }) + .where(eq(featureFlags.name, name)) + .returning(); + + if (!flag) { + throw new UserError(`Feature flag "${name}" not found`); + } + return flag; + }, + + grantAccess: async ( + flagName: string, + access: { guildId?: string; userId?: string; roleId?: string } + ) => { + const flag = await DrizzleClient.query.featureFlags.findFirst({ + where: eq(featureFlags.name, flagName), + }); + if (!flag) throw new UserError(`Feature flag "${flagName}" not found`); + + const [accessRecord] = await DrizzleClient.insert(featureFlagAccess).values({ + flagId: flag.id, + guildId: access.guildId ? BigInt(access.guildId) : null, + userId: access.userId ? BigInt(access.userId) : null, + roleId: access.roleId ? BigInt(access.roleId) : null, + }).returning(); + return accessRecord; + }, + + revokeAccess: async (accessId: number) => { + const [access] = await DrizzleClient.delete(featureFlagAccess) + .where(eq(featureFlagAccess.id, accessId)) + .returning(); + + if (!access) { + throw new UserError(`Access record "${accessId}" not found`); + } + return access; + }, + + getFlag: async (name: string) => { + return await DrizzleClient.query.featureFlags.findFirst({ + where: eq(featureFlags.name, name), + }); + }, + + listFlags: async () => { + return await DrizzleClient.query.featureFlags.findMany({ + orderBy: (flags, { asc }) => [asc(flags.name)], + }); + }, + + listAccess: async (flagName: string) => { + const flag = await DrizzleClient.query.featureFlags.findFirst({ + where: eq(featureFlags.name, flagName), + }); + if (!flag) return []; + + return await DrizzleClient.query.featureFlagAccess.findMany({ + where: eq(featureFlagAccess.flagId, flag.id), + orderBy: (access, { asc }) => [asc(access.id)], + }); + }, + + deleteFlag: async (name: string) => { + const [flag] = await DrizzleClient.delete(featureFlags) + .where(eq(featureFlags.name, name)) + .returning(); + + if (!flag) { + throw new UserError(`Feature flag "${name}" not found`); + } + return flag; + }, +};