1
2import { Injectable, OnModuleInit } from "@nestjs/common";
3import { Cron, CronExpression } from "@nestjs/schedule";
4import { PrismaService } from "../prisma/prisma.service";
5import { createHash } from "crypto";
6
7interface FlagConfig {
8 key: string;
9 enabled: boolean;
10 percentage: number;
11 rules: Rule[];
12 variants: Variant[];
13 defaultVariant: string;
14}
15
16interface Rule {
17 conditions: Condition[];
18 variant: string;
19 priority: number;
20}
21
22interface Condition {
23 attribute: string;
24 operator: "eq" | "neq" | "in" | "contains" | "starts_with";
25 values: string[];
26}
27
28interface Variant {
29 key: string;
30 weight: number;
31}
32
33export interface EvalContext {
34 userId: string;
35 email?: string;
36 plan?: string;
37 country?: string;
38 properties?: Record<string, string>;
39}
40
41export interface EvalResult {
42 enabled: boolean;
43 variant: string;
44 reason: string;
45}
46
47@Injectable()
48export class FlagEvaluatorService implements OnModuleInit {
49 private flags = new Map<string, FlagConfig>();
50
51 constructor(private prisma: PrismaService) {}
52
53 async onModuleInit() {
54 await this.syncFlags();
55 }
56
57 @Cron(CronExpression.EVERY_10_SECONDS)
58 async syncFlags() {
59 const dbFlags = await this.prisma.featureFlag.findMany({
60 where: { enabled: true },
61 });
62
63 const newFlags = new Map<string, FlagConfig>();
64 for (const flag of dbFlags) {
65 newFlags.set(flag.key, {
66 key: flag.key,
67 enabled: flag.enabled,
68 percentage: flag.percentage,
69 rules: flag.rules as Rule[],
70 variants: flag.variants as Variant[],
71 defaultVariant: flag.defaultVariant,
72 });
73 }
74 this.flags = newFlags;
75 }
76
77 evaluate(flagKey: string, context: EvalContext): EvalResult {
78 const flag = this.flags.get(flagKey);
79 if (!flag || !flag.enabled) {
80 return { enabled: false, variant: "", reason: "disabled" };
81 }
82
83
84 const sortedRules = [...flag.rules].sort(
85 (a, b) => b.priority - a.priority
86 );
87 for (const rule of sortedRules) {
88 if (this.matchesAll(rule.conditions, context)) {
89 return { enabled: true, variant: rule.variant, reason: "rule_match" };
90 }
91 }
92
93
94 if (flag.percentage > 0 && flag.percentage < 100) {
95 const bucket = this.hashBucket(flagKey, context.userId);
96 if (bucket < flag.percentage) {
97 return {
98 enabled: true,
99 variant: flag.defaultVariant,
100 reason: "percentage",
101 };
102 }
103 return { enabled: false, variant: "", reason: "percentage_excluded" };
104 }
105
106 return { enabled: true, variant: flag.defaultVariant, reason: "default" };
107 }
108
109 isEnabled(flagKey: string, context: EvalContext): boolean {
110 return this.evaluate(flagKey, context).enabled;
111 }
112
113 private hashBucket(flagKey: string, userId: string): number {
114 const hash = createHash("sha256")
115 .update(`${flagKey}:${userId}`)
116 .digest();
117 return (hash.readUInt32BE(0) / 0xffffffff) * 100;
118 }
119
120 private matchesAll(conditions: Condition[], ctx: EvalContext): boolean {
121 return conditions.every((c) => this.matchCondition(c, ctx));
122 }
123
124 private matchCondition(cond: Condition, ctx: EvalContext): boolean {
125 const value = this.getAttribute(cond.attribute, ctx);
126 switch (cond.operator) {
127 case "eq": return cond.values[0] === value;
128 case "neq": return cond.values[0] !== value;
129 case "in": return cond.values.includes(value);
130 case "contains": return value.includes(cond.values[0]);
131 case "starts_with": return value.startsWith(cond.values[0]);
132 default: return false;
133 }
134 }
135
136 private getAttribute(attr: string, ctx: EvalContext): string {
137 const map: Record<string, string | undefined> = {
138 user_id: ctx.userId,
139 email: ctx.email,
140 plan: ctx.plan,
141 country: ctx.country,
142 };
143 return map[attr] ?? ctx.properties?.[attr] ?? "";
144 }
145}
146