forked from wangziqi/gongxue-base
feat: auto grant badges for learning milestones
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
} from '../../core/request.js';
|
||||
import { query, queryOne, transaction } from '../../core/db.js';
|
||||
import { assertAnswerSessionAccess, authorizePracticeSession, recordPracticeAccessEvent } from './access.js';
|
||||
import { autoGrantBadges, type AutoBadgeGrant } from '../profile/badges.js';
|
||||
|
||||
interface QuestionAnswerRow {
|
||||
question_id: string;
|
||||
@@ -93,6 +94,11 @@ interface PracticeSessionReportRow {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface PracticeSessionReportWithBadges {
|
||||
report: PracticeSessionReportRow;
|
||||
autoBadges: AutoBadgeGrant[];
|
||||
}
|
||||
|
||||
interface PracticeSessionReportAnswerRow {
|
||||
questionId: string;
|
||||
selectedOptions: unknown;
|
||||
@@ -616,6 +622,128 @@ function round4(value: number) {
|
||||
return Math.round(value * 10000) / 10000;
|
||||
}
|
||||
|
||||
async function totalPracticeReportCount(client: pg.PoolClient, tenantId: string, userId: string) {
|
||||
const result = await client.query<{ count: string }>(
|
||||
`
|
||||
select count(*)::text as count
|
||||
from public.practice_session_reports
|
||||
where tenant_id = $1 and user_id = $2
|
||||
`,
|
||||
[tenantId, userId],
|
||||
);
|
||||
return Number(result.rows[0]?.count || 0);
|
||||
}
|
||||
|
||||
async function bestMockExamScore(client: pg.PoolClient, tenantId: string, userId: string) {
|
||||
const result = await client.query<{ score: string | number | null }>(
|
||||
`
|
||||
select max(score) as score
|
||||
from public.practice_session_reports
|
||||
where tenant_id = $1
|
||||
and user_id = $2
|
||||
and mode = 'mock_exam'
|
||||
`,
|
||||
[tenantId, userId],
|
||||
);
|
||||
return finiteNumber(result.rows[0]?.score, 0);
|
||||
}
|
||||
|
||||
async function masteredWordCount(client: pg.PoolClient, tenantId: string, userId: string) {
|
||||
const result = await client.query<{ count: string }>(
|
||||
`
|
||||
select count(*)::text as count
|
||||
from public.user_word_progress
|
||||
where tenant_id = $1
|
||||
and user_id = $2
|
||||
and status = 'mastered'
|
||||
`,
|
||||
[tenantId, userId],
|
||||
);
|
||||
return Number(result.rows[0]?.count || 0);
|
||||
}
|
||||
|
||||
async function grantLearningBadgesForPracticeReport(
|
||||
client: pg.PoolClient,
|
||||
input: {
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
report: PracticeSessionReportRow;
|
||||
},
|
||||
) {
|
||||
const practiceCount = await totalPracticeReportCount(client, input.tenantId, input.userId);
|
||||
const grants: AutoBadgeGrant[] = [
|
||||
...(await autoGrantBadges(client, {
|
||||
tenantId: input.tenantId,
|
||||
userId: input.userId,
|
||||
trigger: 'practice_count',
|
||||
evidence: {
|
||||
practiceCount,
|
||||
practiceReportCount: practiceCount,
|
||||
latestReportId: input.report.id,
|
||||
latestPracticeSessionId: input.report.practiceSessionId,
|
||||
latestPracticeMode: input.report.mode,
|
||||
latestAnsweredCount: Number(input.report.answeredCount || 0),
|
||||
latestCorrectCount: Number(input.report.correctCount || 0),
|
||||
latestAccuracy: finiteNumber(input.report.accuracy, 0),
|
||||
},
|
||||
})),
|
||||
];
|
||||
|
||||
if (input.report.mode === 'mock_exam') {
|
||||
const mockExamScore = finiteNumber(input.report.score, 0);
|
||||
const mockExamTotalScore = finiteNumber(input.report.totalScore, 0);
|
||||
grants.push(
|
||||
...(await autoGrantBadges(client, {
|
||||
tenantId: input.tenantId,
|
||||
userId: input.userId,
|
||||
trigger: 'mock_exam_score',
|
||||
evidence: {
|
||||
mockExamScore,
|
||||
mockExamTotalScore,
|
||||
mockExamAccuracy: finiteNumber(input.report.accuracy, 0),
|
||||
mockExamBestScore: await bestMockExamScore(client, input.tenantId, input.userId),
|
||||
practiceCount,
|
||||
reportId: input.report.id,
|
||||
practiceSessionId: input.report.practiceSessionId,
|
||||
blueprintId: input.report.blueprintId,
|
||||
collectionId: input.report.collectionId,
|
||||
},
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
return grants;
|
||||
}
|
||||
|
||||
async function grantVocabularyMasteredBadges(
|
||||
client: pg.PoolClient,
|
||||
input: {
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
progress: Record<string, unknown>;
|
||||
source: string;
|
||||
},
|
||||
) {
|
||||
if (input.progress.status !== 'mastered') return [];
|
||||
const count = await masteredWordCount(client, input.tenantId, input.userId);
|
||||
return autoGrantBadges(client, {
|
||||
tenantId: input.tenantId,
|
||||
userId: input.userId,
|
||||
trigger: 'vocabulary_mastered',
|
||||
evidence: {
|
||||
vocabularyMasteredCount: count,
|
||||
masteredWordsCount: count,
|
||||
progressId: input.progress.id,
|
||||
wordId: input.progress.wordId,
|
||||
correctCount: input.progress.correctCount,
|
||||
wrongCount: input.progress.wrongCount,
|
||||
reviewCount: input.progress.reviewCount,
|
||||
correctStreak: input.progress.correctStreak,
|
||||
source: input.source,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function dateParam(ctx: RequestContext, name: string) {
|
||||
const value = stringParam(ctx, name);
|
||||
if (!value) return '';
|
||||
@@ -1547,8 +1675,18 @@ export async function submitPracticeSessionRoute(ctx: RequestContext) {
|
||||
const userId = await userIdFrom(ctx, body);
|
||||
const practiceSessionId = requiredString(body, 'practiceSessionId');
|
||||
|
||||
const report = await transaction(async client => buildPracticeSessionReport(client, tenantId, userId, practiceSessionId));
|
||||
return { item: formatReport(report) };
|
||||
const result = await transaction<PracticeSessionReportWithBadges>(async client => {
|
||||
const report = await buildPracticeSessionReport(client, tenantId, userId, practiceSessionId);
|
||||
const autoBadges = await grantLearningBadgesForPracticeReport(client, { tenantId, userId, report });
|
||||
return { report, autoBadges };
|
||||
});
|
||||
|
||||
return {
|
||||
item: {
|
||||
...formatReport(result.report),
|
||||
...(result.autoBadges.length ? { autoBadges: result.autoBadges } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function practiceSessionReportRoute(ctx: RequestContext) {
|
||||
@@ -2462,7 +2600,7 @@ export async function reviewWordRoute(ctx: RequestContext) {
|
||||
[tenantId, userId],
|
||||
);
|
||||
|
||||
return {
|
||||
const item = {
|
||||
...progress.rows[0],
|
||||
review: {
|
||||
result,
|
||||
@@ -2470,6 +2608,18 @@ export async function reviewWordRoute(ctx: RequestContext) {
|
||||
nextReviewDate: schedule.nextReviewDate,
|
||||
},
|
||||
};
|
||||
|
||||
const autoBadges = await grantVocabularyMasteredBadges(client, {
|
||||
tenantId,
|
||||
userId,
|
||||
progress: item,
|
||||
source: 'review_word',
|
||||
});
|
||||
|
||||
return {
|
||||
...item,
|
||||
...(autoBadges.length ? { autoBadges } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
return { item };
|
||||
@@ -2541,7 +2691,18 @@ export async function updateWordProgressRoute(ctx: RequestContext) {
|
||||
[tenantId, userId],
|
||||
);
|
||||
|
||||
return progress.rows[0];
|
||||
const item = progress.rows[0];
|
||||
const autoBadges = await grantVocabularyMasteredBadges(client, {
|
||||
tenantId,
|
||||
userId,
|
||||
progress: item,
|
||||
source: 'update_word_progress',
|
||||
});
|
||||
|
||||
return {
|
||||
...item,
|
||||
...(autoBadges.length ? { autoBadges } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
return { item };
|
||||
|
||||
@@ -3,7 +3,14 @@ import { createUserNotification } from '../notifications/service.js';
|
||||
|
||||
type JsonMap = Record<string, unknown>;
|
||||
|
||||
export type BadgeTrigger = 'check_in' | 'score' | 'feedback_resolved' | 'activity_reward';
|
||||
export type BadgeTrigger =
|
||||
| 'check_in'
|
||||
| 'score'
|
||||
| 'feedback_resolved'
|
||||
| 'activity_reward'
|
||||
| 'practice_count'
|
||||
| 'vocabulary_mastered'
|
||||
| 'mock_exam_score';
|
||||
|
||||
export interface BadgeMetricEvidence {
|
||||
checkInStreak?: number;
|
||||
@@ -11,6 +18,14 @@ export interface BadgeMetricEvidence {
|
||||
score?: number;
|
||||
feedbackResolvedCount?: number;
|
||||
rewardPoints?: number;
|
||||
practiceCount?: number;
|
||||
practiceReportCount?: number;
|
||||
vocabularyMasteredCount?: number;
|
||||
masteredWordsCount?: number;
|
||||
mockExamScore?: number;
|
||||
mockExamTotalScore?: number;
|
||||
mockExamAccuracy?: number;
|
||||
mockExamBestScore?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -57,6 +72,9 @@ const TRIGGER_UNLOCK_TYPES: Record<BadgeTrigger, string[]> = {
|
||||
score: ['score'],
|
||||
feedback_resolved: ['feedback_resolved'],
|
||||
activity_reward: ['activity_reward'],
|
||||
practice_count: ['practice_count'],
|
||||
vocabulary_mastered: ['vocabulary_mastered'],
|
||||
mock_exam_score: ['mock_exam_score'],
|
||||
};
|
||||
|
||||
const FIELD_ALIASES: Record<string, string[]> = {
|
||||
@@ -66,6 +84,12 @@ const FIELD_ALIASES: Record<string, string[]> = {
|
||||
feedback_resolved: ['feedbackResolvedCount', 'feedback.resolvedCount', 'reports.resolvedCount'],
|
||||
reward_points: ['rewardPoints', 'feedback.rewardPoints'],
|
||||
activity_reward: ['activityRewardCount', 'activity.rewardCount', 'tasks.claimCount'],
|
||||
practice_count: ['practiceCount', 'practice.count', 'practice.reportCount', 'practiceReportCount', 'reports.practiceCount'],
|
||||
vocabulary_mastered: ['vocabularyMasteredCount', 'masteredWordsCount', 'vocabulary.masteredCount', 'words.masteredCount'],
|
||||
mock_exam_score: ['mockExamScore', 'mockExam.score', 'report.score', 'score'],
|
||||
mock_exam_total_score: ['mockExamTotalScore', 'mockExam.totalScore', 'report.totalScore'],
|
||||
mock_exam_accuracy: ['mockExamAccuracy', 'mockExam.accuracy', 'report.accuracy'],
|
||||
mock_exam_best_score: ['mockExamBestScore', 'mockExam.bestScore', 'reports.mockExamBestScore'],
|
||||
};
|
||||
|
||||
function numberValue(value: unknown) {
|
||||
@@ -88,6 +112,9 @@ function metricValueForField(field: string | null, evidence: BadgeMetricEvidence
|
||||
if (trigger === 'score') return numberValue(evidence.score);
|
||||
if (trigger === 'feedback_resolved') return numberValue(evidence.feedbackResolvedCount);
|
||||
if (trigger === 'activity_reward') return numberValue(evidence.activityRewardCount);
|
||||
if (trigger === 'practice_count') return numberValue(evidence.practiceCount ?? evidence.practiceReportCount);
|
||||
if (trigger === 'vocabulary_mastered') return numberValue(evidence.vocabularyMasteredCount ?? evidence.masteredWordsCount);
|
||||
if (trigger === 'mock_exam_score') return numberValue(evidence.mockExamScore);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -99,6 +126,12 @@ function metricValueForField(field: string | null, evidence: BadgeMetricEvidence
|
||||
if (canonical === 'feedback_resolved') return numberValue(evidence.feedbackResolvedCount);
|
||||
if (canonical === 'reward_points') return numberValue(evidence.rewardPoints);
|
||||
if (canonical === 'activity_reward') return numberValue(evidence.activityRewardCount);
|
||||
if (canonical === 'practice_count') return numberValue(evidence.practiceCount ?? evidence.practiceReportCount);
|
||||
if (canonical === 'vocabulary_mastered') return numberValue(evidence.vocabularyMasteredCount ?? evidence.masteredWordsCount);
|
||||
if (canonical === 'mock_exam_score') return numberValue(evidence.mockExamScore);
|
||||
if (canonical === 'mock_exam_total_score') return numberValue(evidence.mockExamTotalScore);
|
||||
if (canonical === 'mock_exam_accuracy') return numberValue(evidence.mockExamAccuracy);
|
||||
if (canonical === 'mock_exam_best_score') return numberValue(evidence.mockExamBestScore);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user