Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,7 @@
## 2026-07-16 - [O(N*M) Nested Loop Lookups in Grading Engines]
**Learning:** In interactive scenarios (such as Bid Elevator and STR Triage), grading engines frequently iterate over user decisions and match them against scenario properties (like keywords or search terms). Performing `array.find()` inside loop bodies or filter predicates results in costly O(N*M) lookups.
**Action:** Convert arrays to `Map` lookups before entering loops/nested scans. Mapping keys once in O(M) time enables O(1) lookups during execution, transforming the time complexity of the grading logic to O(N + M).

## 2026-07-17 - [Redundant Database Hits in Badge Rules Loops]
**Learning:** Evaluators checking multiple rules (such as `checkCriteria` in the badge engine) can generate redundant database queries for identical user records or resource aggregates when looping over each rule. Caching query promises rather than resolved values in a transient cache avoids multiple database roundtrips and safely collapses them from O(R) to O(1) where R is the number of rules.
**Action:** Utilize an `EvaluationCache` to cache and share database query promises across criteria checks during the lifecycle of an evaluation call.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,5 +72,5 @@
"eslint --fix"
]
},
"packageManager": "pnpm@11.13.0+sha512.88d94724d8f2e6c186744a5584c6e59ecac869ec7ba15e9cb4cd628e8dc7066820b2481d8ee3b51ea8da323a7378068aa58c556a3720d32b7c20a051d088363a"
"packageManager": "pnpm@11.13.1"
}
70 changes: 70 additions & 0 deletions src/lib/__tests__/badges.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,4 +92,74 @@ describe('badges.ts', () => {
const result = await evaluateBadges('user-1', { trigger: 'login' });
expect(result.awarded).toEqual([]);
});

it('awards module_complete badge when criteria met', async () => {
(db.badge.findMany as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: 'b1', title: 'Module', slug: 'module', criteria: JSON.stringify({ type: 'module_complete', threshold: 1 }), xpReward: 20, description: '', icon: '', tier: 'BRONZE', isPublished: true, deletedAt: null },
]);
(db.userBadge.findMany as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(db.lessonProgress.count as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(1);
const result = await evaluateBadges('user-1', { trigger: 'lesson_complete' });
expect(result.awarded).toHaveLength(1);
expect(result.awarded[0]?.slug).toBe('module');
});

it('awards tool_sessions badge when criteria met (general and scope specific)', async () => {
(db.badge.findMany as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: 'b1', title: 'Sessions', criteria: JSON.stringify({ type: 'tool_sessions', threshold: 5 }), xpReward: 20, description: '', icon: '', tier: 'BRONZE', isPublished: true, deletedAt: null },
{ id: 'b2', title: 'Specific Tool', criteria: JSON.stringify({ type: 'tool_sessions', threshold: 3, scope: { toolType: 'CAMPAIGN_BUILDER' } }), xpReward: 30, description: '', icon: '', tier: 'SILVER', isPublished: true, deletedAt: null },
]);
(db.userBadge.findMany as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([]);

// Mock general tool sessions to return 5 and specific campaign builder to return 3
(db.toolSession.count as unknown as ReturnType<typeof vi.fn>).mockImplementation(async (args: any) => {
if (args?.where?.toolType === 'CAMPAIGN_BUILDER') {
return 3;
}
return 5;
});

const result = await evaluateBadges('user-1', { trigger: 'tool_submit', toolType: 'CAMPAIGN_BUILDER', passed: true });
expect(result.awarded).toHaveLength(2);
expect(result.totalXpGained).toBe(50);
});

it('does not award quiz_score badge when trigger is not quiz_submit or failed', async () => {
(db.badge.findMany as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: 'b1', title: 'Quiz', criteria: JSON.stringify({ type: 'quiz_score', threshold: 100 }), xpReward: 20, description: '', icon: '', tier: 'BRONZE', isPublished: true, deletedAt: null },
]);
(db.userBadge.findMany as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([]);

// Scenario 1: trigger is not quiz_submit
let result = await evaluateBadges('user-1', { trigger: 'login' });
expect(result.awarded).toEqual([]);

// Scenario 2: quiz failed
result = await evaluateBadges('user-1', { trigger: 'quiz_submit', score: 100, passed: false });
expect(result.awarded).toEqual([]);

// Scenario 3: score below threshold
result = await evaluateBadges('user-1', { trigger: 'quiz_submit', score: 90, passed: true });
expect(result.awarded).toEqual([]);

// Scenario 4: passes successfully
result = await evaluateBadges('user-1', { trigger: 'quiz_submit', score: 100, passed: true });
expect(result.awarded).toHaveLength(1);
});

it('caches the queries correctly in transient EvaluationCache during execution', async () => {
(db.badge.findMany as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: 'b1', title: 'Streak', criteria: JSON.stringify({ type: 'streak_days', threshold: 7 }), xpReward: 30, description: '', icon: '', tier: 'SILVER', isPublished: true, deletedAt: null },
{ id: 'b2', title: 'XP', criteria: JSON.stringify({ type: 'xp_threshold', threshold: 100 }), xpReward: 50, description: '', icon: '', tier: 'BRONZE', isPublished: true, deletedAt: null },
]);
(db.userBadge.findMany as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([]);

const findUniqueSpy = vi.spyOn(db.user, 'findUnique');
findUniqueSpy.mockResolvedValue({ streakDays: 10, xp: 150 } as any);

const result = await evaluateBadges('user-1', { trigger: 'login' });
expect(result.awarded).toHaveLength(2);
// Should only query the user profile once because of the cache!
expect(findUniqueSpy).toHaveBeenCalledTimes(1);
});
});
80 changes: 61 additions & 19 deletions src/lib/badges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,57 @@ export interface BadgeEvaluationResult {
* wants to count it; the engine does NOT mutate `User.xp` to keep this function
* composable inside larger transactions.
*/
/**
* Transient cache to hold and share database query promises across criteria checks
* during a single evaluateBadges execution. This prevents multiple identical or
* redundant database calls and collapses DB roundtrips from O(R) to O(1) where R is the number of rules.
*/
class EvaluationCache {
private lessonCompletedCountPromise: Promise<number> | null = null;
private toolSessionCountPromises = new Map<string, Promise<number>>();
private generalToolSessionCountPromise: Promise<number> | null = null;
private userProfilePromise: Promise<{ streakDays: number; xp: number } | null> | null = null;

getLessonCompletedCount(userId: string): Promise<number> {
if (!this.lessonCompletedCountPromise) {
this.lessonCompletedCountPromise = db.lessonProgress.count({
where: { userId, status: 'COMPLETED' },
});
}
return this.lessonCompletedCountPromise!;
}

getToolSessionCount(userId: string, toolType?: string): Promise<number> {
if (toolType) {
const cached = this.toolSessionCountPromises.get(toolType);
if (cached) return cached;

const promise = db.toolSession.count({
where: { userId, status: 'GRADED', toolType },
});
this.toolSessionCountPromises.set(toolType, promise);
return promise;
} else {
if (!this.generalToolSessionCountPromise) {
this.generalToolSessionCountPromise = db.toolSession.count({
where: { userId, status: 'GRADED' },
});
}
return this.generalToolSessionCountPromise!;
}
}

getUserProfile(userId: string): Promise<{ streakDays: number; xp: number } | null> {
if (!this.userProfilePromise) {
this.userProfilePromise = db.user.findUnique({
where: { id: userId },
select: { streakDays: true, xp: true },
}) as Promise<{ streakDays: number; xp: number } | null>;
}
return this.userProfilePromise!;
}
}

export async function evaluateBadges(
userId: string,
event: BadgeTrigger,
Expand Down Expand Up @@ -95,6 +146,8 @@ export async function evaluateBadges(
xpReward: number;
}> = [];

const cache = new EvaluationCache();

for (const badge of published) {
if (alreadyAwardedSet.has(badge.id)) continue;

Expand All @@ -106,7 +159,7 @@ export async function evaluateBadges(
continue;
}

const qualifies = await checkCriteria(userId, criteria, event);
const qualifies = await checkCriteria(userId, criteria, event, cache);
if (qualifies) earnedNow.push(badge);
}

Expand Down Expand Up @@ -141,17 +194,18 @@ export async function evaluateBadges(
/**
* Returns true if the user has met the given badge criteria at this moment.
* Each branch is a narrow DB read β€” no writes.
*
* Bolt optimization: uses EvaluationCache to reuse query promises.
*/
async function checkCriteria(
userId: string,
criteria: BadgeCriteria,
event: BadgeTrigger,
cache: EvaluationCache,
): Promise<boolean> {
switch (criteria.type) {
case 'module_complete': {
const completedCount = await db.lessonProgress.count({
where: { userId, status: 'COMPLETED' },
});
const completedCount = await cache.getLessonCompletedCount(userId);
// Treat each completed lesson as progress toward module_complete; the
// seed threshold is 1 so this triggers after the first lesson.
return completedCount >= criteria.threshold;
Expand All @@ -165,30 +219,18 @@ async function checkCriteria(

case 'tool_sessions': {
const scopeToolType = criteria.scope?.toolType;
const count = await db.toolSession.count({
where: {
userId,
status: 'GRADED',
...(scopeToolType ? { toolType: scopeToolType } : {}),
},
});
const count = await cache.getToolSessionCount(userId, scopeToolType);
return count >= criteria.threshold;
}

case 'streak_days': {
const user = await db.user.findUnique({
where: { id: userId },
select: { streakDays: true },
});
const user = await cache.getUserProfile(userId);
if (!user) return false;
return user.streakDays >= criteria.threshold;
}

case 'xp_threshold': {
const user = await db.user.findUnique({
where: { id: userId },
select: { xp: true },
});
const user = await cache.getUserProfile(userId);
if (!user) return false;
return user.xp >= criteria.threshold;
}
Expand Down