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 - [N+1 DB Queries in Sequential Rules Evaluation]
**Learning:** Sequential evaluation of rules or criteria checks (such as checking badge conditions like module completion, streak days, and XP totals inside loop blocks) can lead to an N+1 query pattern where the database is repeatedly queried for identical aggregate values or user profile fields. Sharing or caching the query promises rather than resolved values via a transient cache during the evaluation lifecycle collapses duplicate roundtrips from O(R) to O(1), where R is the number of rules.
**Action:** Utilize a transient context-bound cache containing query promises (e.g., `EvaluationCache`) for evaluations involving multi-rule DB lookups.
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.14.0"
}
75 changes: 75 additions & 0 deletions src/lib/__tests__/badges.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,4 +92,79 @@ 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', 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(2);
const result = await evaluateBadges('user-1', { trigger: 'lesson_complete' });
expect(result.awarded).toHaveLength(1);
expect(result.totalXpGained).toBe(20);
});

it('awards quiz_score badge when criteria met', async () => {
(db.badge.findMany as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: 'b1', title: 'Quiz Master', criteria: JSON.stringify({ type: 'quiz_score', threshold: 90 }), xpReward: 40, description: '', icon: '', tier: 'GOLD', isPublished: true, deletedAt: null },
]);
(db.userBadge.findMany as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const result = await evaluateBadges('user-1', { trigger: 'quiz_submit', score: 95, passed: true });
expect(result.awarded).toHaveLength(1);
expect(result.totalXpGained).toBe(40);
});

it('does not award quiz_score badge when not passed', async () => {
(db.badge.findMany as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: 'b1', title: 'Quiz Master', criteria: JSON.stringify({ type: 'quiz_score', threshold: 90 }), xpReward: 40, description: '', icon: '', tier: 'GOLD', isPublished: true, deletedAt: null },
]);
(db.userBadge.findMany as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const result = await evaluateBadges('user-1', { trigger: 'quiz_submit', score: 95, passed: false });
expect(result.awarded).toHaveLength(0);
});

it('does not award quiz_score badge when non-quiz trigger', async () => {
(db.badge.findMany as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: 'b1', title: 'Quiz Master', criteria: JSON.stringify({ type: 'quiz_score', threshold: 90 }), xpReward: 40, description: '', icon: '', tier: 'GOLD', isPublished: true, deletedAt: null },
]);
(db.userBadge.findMany as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const result = await evaluateBadges('user-1', { trigger: 'login' });
expect(result.awarded).toHaveLength(0);
});

it('awards tool_sessions badge when criteria met', async () => {
(db.badge.findMany as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: 'b1', title: 'Tool Pro', criteria: JSON.stringify({ type: 'tool_sessions', threshold: 5 }), xpReward: 50, description: '', icon: '', tier: 'SILVER', isPublished: true, deletedAt: null },
]);
(db.userBadge.findMany as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(db.toolSession.count as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(5);
const result = await evaluateBadges('user-1', { trigger: 'tool_submit', toolType: 'CAMPAIGN_BUILDER', passed: true });
expect(result.awarded).toHaveLength(1);
expect(result.totalXpGained).toBe(50);
});

it('awards tool_sessions scoped badge when criteria met', async () => {
(db.badge.findMany as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: 'b1', title: 'Campaign Builder Pro', criteria: JSON.stringify({ type: 'tool_sessions', threshold: 3, scope: { toolType: 'CAMPAIGN_BUILDER' } }), xpReward: 50, description: '', icon: '', tier: 'SILVER', isPublished: true, deletedAt: null },
]);
(db.userBadge.findMany as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(db.toolSession.count as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(3);
const result = await evaluateBadges('user-1', { trigger: 'tool_submit', toolType: 'CAMPAIGN_BUILDER', passed: true });
expect(result.awarded).toHaveLength(1);
expect(result.totalXpGained).toBe(50);
});

it('EvaluationCache caches database query promises and prevents N+1 queries', async () => {
(db.badge.findMany as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: 'b1', title: 'Streak 1', criteria: JSON.stringify({ type: 'streak_days', threshold: 3 }), xpReward: 10, description: '', icon: '', tier: 'BRONZE', isPublished: true, deletedAt: null },
{ id: 'b2', title: 'Streak 2', criteria: JSON.stringify({ type: 'streak_days', threshold: 7 }), xpReward: 20, description: '', icon: '', tier: 'SILVER', isPublished: true, deletedAt: null },
]);
(db.userBadge.findMany as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(db.user.findUnique as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({ streakDays: 10, xp: 0 });

const result = await evaluateBadges('user-1', { trigger: 'login' });
expect(result.awarded).toHaveLength(2);
// Verified that db.user.findUnique was only called ONCE for both criteria because of EvaluationCache!
expect(db.user.findUnique).toHaveBeenCalledTimes(1);
});
});
56 changes: 38 additions & 18 deletions src/lib/badges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export async function evaluateBadges(
where: { userId },
select: { badgeId: true },
});
const alreadyAwardedSet = new Set(alreadyAwarded.map((ub) => ub.badgeId));
const alreadyAwardedSet = new Set(alreadyAwarded.map((ub: { badgeId: string }) => ub.badgeId));

const earnedNow: Array<{
id: string;
Expand All @@ -95,6 +95,9 @@ export async function evaluateBadges(
xpReward: number;
}> = [];

// Transient EvaluationCache holds query promises to avoid N+1 query pattern during evaluation
const cache = new EvaluationCache();

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

Expand All @@ -106,13 +109,13 @@ 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);
}

// Persist awards in one transaction so partial failures roll back cleanly.
if (earnedNow.length > 0) {
await db.$transaction(async (tx) => {
await db.$transaction(async (tx: Parameters<Parameters<typeof db.$transaction>[0]>[0]) => {
for (const badge of earnedNow) {
await tx.userBadge.create({
data: { userId, badgeId: badge.id },
Expand Down Expand Up @@ -142,18 +145,28 @@ 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.
*/
class EvaluationCache {
lessonCompletedCount: Promise<number> | null = null;
user: Promise<{ streakDays: number; xp: number } | null> | null = null;
toolSessionCount = new Map<string, Promise<number>>();
}

/**
* Returns true if the user has met the given badge criteria at this moment.
* Uses shared promises from EvaluationCache to collapse O(R) database roundtrips to O(1).
*/
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({
cache.lessonCompletedCount ??= db.lessonProgress.count({
where: { userId, status: 'COMPLETED' },
});
// Treat each completed lesson as progress toward module_complete; the
// seed threshold is 1 so this triggers after the first lesson.
const completedCount = await cache.lessonCompletedCount!;
return completedCount >= criteria.threshold;
}

Expand All @@ -164,31 +177,38 @@ 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 toolType = criteria.scope?.toolType || 'ALL';
let promise = cache.toolSessionCount.get(toolType);
if (!promise) {
promise = db.toolSession.count({
where: {
userId,
status: 'GRADED',
...(criteria.scope?.toolType ? { toolType: criteria.scope.toolType } : {}),
},
});
cache.toolSessionCount.set(toolType, promise);
}
const count = await promise;
return count >= criteria.threshold;
}

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

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