forked from wangziqi/gongxue-base
feat: add learning stats and review plans
This commit is contained in:
@@ -3,7 +3,10 @@ import {
|
||||
createPracticeSessionRoute,
|
||||
favoriteQuestionsRoute,
|
||||
favoriteWordsRoute,
|
||||
learningStatsRoute,
|
||||
learningTrendRoute,
|
||||
practiceReportsRoute,
|
||||
practiceHistoryRoute,
|
||||
practiceSessionReportRoute,
|
||||
resolveWrongQuestionRoute,
|
||||
submitAnswerRoute,
|
||||
@@ -13,6 +16,7 @@ import {
|
||||
updateWordProgressRoute,
|
||||
wordProgressRoute,
|
||||
wordStatsRoute,
|
||||
wrongQuestionReviewPlanRoute,
|
||||
wrongQuestionsRoute,
|
||||
} from './routes.js';
|
||||
|
||||
@@ -20,11 +24,15 @@ export const learningRoutes: RouteDefinition[] = [
|
||||
['POST', '/api/learning/practice-sessions', createPracticeSessionRoute],
|
||||
['POST', '/api/learning/practice-sessions/submit', submitPracticeSessionRoute],
|
||||
['GET', '/api/learning/practice-sessions/report', practiceSessionReportRoute],
|
||||
['GET', '/api/learning/practice-sessions/history', practiceHistoryRoute],
|
||||
['GET', '/api/learning/practice-reports', practiceReportsRoute],
|
||||
['GET', '/api/learning/stats', learningStatsRoute],
|
||||
['GET', '/api/learning/trend', learningTrendRoute],
|
||||
['POST', '/api/learning/answers', submitAnswerRoute],
|
||||
['GET', '/api/learning/favorites/questions', favoriteQuestionsRoute],
|
||||
['POST', '/api/learning/favorites/questions', toggleFavoriteQuestionRoute],
|
||||
['GET', '/api/learning/wrong-questions', wrongQuestionsRoute],
|
||||
['GET', '/api/learning/wrong-questions/review-plan', wrongQuestionReviewPlanRoute],
|
||||
['POST', '/api/learning/wrong-questions/resolve', resolveWrongQuestionRoute],
|
||||
['GET', '/api/learning/vocabulary/progress', wordProgressRoute],
|
||||
['POST', '/api/learning/vocabulary/progress', updateWordProgressRoute],
|
||||
|
||||
@@ -160,6 +160,18 @@ interface PracticeAssembly {
|
||||
rules: Record<string, unknown>;
|
||||
}
|
||||
|
||||
type TrendMetric = {
|
||||
date: string;
|
||||
answeredCount: number;
|
||||
correctCount: number;
|
||||
wrongCount: number;
|
||||
accuracy: number;
|
||||
sessionCount: number;
|
||||
reportCount: number;
|
||||
score: number;
|
||||
totalScore: number;
|
||||
};
|
||||
|
||||
function normalizeStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.map(item => String(item)).filter(item => item !== '');
|
||||
@@ -234,6 +246,15 @@ function round4(value: number) {
|
||||
return Math.round(value * 10000) / 10000;
|
||||
}
|
||||
|
||||
function dateParam(ctx: RequestContext, name: string) {
|
||||
const value = stringParam(ctx, name);
|
||||
if (!value) return '';
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||||
throw new HttpError(400, `${name} must be YYYY-MM-DD`, 'INVALID_DATE_PARAM');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function idArrayFromJson(value: unknown): string[] {
|
||||
return normalizeStringArray(value);
|
||||
}
|
||||
@@ -465,6 +486,41 @@ async function collectFromLegacyTarget(
|
||||
return rows.map(row => row.id);
|
||||
}
|
||||
|
||||
async function collectWrongReviewQuestions(tenantId: string, userId: string, limit: number) {
|
||||
const rows = await query<{ id: string }>(
|
||||
`
|
||||
select q.id
|
||||
from public.wrong_questions wq
|
||||
join public.questions q on q.id = wq.question_id and q.tenant_id = wq.tenant_id
|
||||
where wq.tenant_id = $1
|
||||
and wq.user_id = $2
|
||||
and wq.resolved_at is null
|
||||
and q.status = 'published'
|
||||
order by wq.wrong_count desc, wq.last_wrong_at asc
|
||||
limit $3
|
||||
`,
|
||||
[tenantId, userId, limit],
|
||||
);
|
||||
return rows.map(row => row.id);
|
||||
}
|
||||
|
||||
async function collectFavoriteReviewQuestions(tenantId: string, userId: string, limit: number) {
|
||||
const rows = await query<{ id: string }>(
|
||||
`
|
||||
select q.id
|
||||
from public.favorite_questions fq
|
||||
join public.questions q on q.id = fq.question_id and q.tenant_id = fq.tenant_id
|
||||
where fq.tenant_id = $1
|
||||
and fq.user_id = $2
|
||||
and q.status = 'published'
|
||||
order by fq.created_at desc
|
||||
limit $3
|
||||
`,
|
||||
[tenantId, userId, limit],
|
||||
);
|
||||
return rows.map(row => row.id);
|
||||
}
|
||||
|
||||
function sectionType(section: unknown) {
|
||||
if (!section || typeof section !== 'object' || Array.isArray(section)) return null;
|
||||
const object = section as Record<string, unknown>;
|
||||
@@ -485,7 +541,7 @@ function sectionLimit(section: unknown, fallback: number) {
|
||||
return positiveInt(object.questionCount ?? object.limit, fallback);
|
||||
}
|
||||
|
||||
async function assembleQuestionIds(tenantId: string, assembly: PracticeAssembly) {
|
||||
async function assembleQuestionIds(tenantId: string, userId: string, assembly: PracticeAssembly) {
|
||||
const randomize = assembly.mode === 'random' || assembly.mode === 'mock_exam' || assembly.rules.randomize === true;
|
||||
const questionIds: string[] = [];
|
||||
const pushUnique = (ids: string[]) => {
|
||||
@@ -494,6 +550,13 @@ async function assembleQuestionIds(tenantId: string, assembly: PracticeAssembly)
|
||||
}
|
||||
};
|
||||
|
||||
if (assembly.mode === 'wrong_review') {
|
||||
return collectWrongReviewQuestions(tenantId, userId, assembly.questionLimit);
|
||||
}
|
||||
if (assembly.mode === 'favorite_review') {
|
||||
return collectFavoriteReviewQuestions(tenantId, userId, assembly.questionLimit);
|
||||
}
|
||||
|
||||
if (assembly.sections.length && (assembly.collectionId || assembly.contentNodeId)) {
|
||||
for (const section of assembly.sections) {
|
||||
const limit = sectionLimit(section, assembly.questionLimit);
|
||||
@@ -521,11 +584,17 @@ export async function createPracticeSessionRoute(ctx: RequestContext) {
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const userId = await userIdFrom(ctx, body);
|
||||
const assembly = await buildPracticeAssembly(tenantId, body);
|
||||
const questionIds = await assembleQuestionIds(tenantId, assembly);
|
||||
const questionIds = await assembleQuestionIds(tenantId, userId, assembly);
|
||||
|
||||
if ((assembly.blueprintId || assembly.collectionId || assembly.contentNodeId) && questionIds.length === 0) {
|
||||
throw new HttpError(409, 'No published questions are available for this practice target', 'NO_PRACTICE_QUESTIONS');
|
||||
}
|
||||
if (assembly.mode === 'wrong_review' && questionIds.length === 0) {
|
||||
throw new HttpError(409, 'No unresolved wrong questions are available for review', 'NO_WRONG_REVIEW_QUESTIONS');
|
||||
}
|
||||
if (assembly.mode === 'favorite_review' && questionIds.length === 0) {
|
||||
throw new HttpError(409, 'No favorite questions are available for review', 'NO_FAVORITE_REVIEW_QUESTIONS');
|
||||
}
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const access = await authorizePracticeSession(client, {
|
||||
@@ -1110,6 +1179,370 @@ export async function practiceReportsRoute(ctx: RequestContext) {
|
||||
return { items: items.map(formatReport) };
|
||||
}
|
||||
|
||||
export async function practiceHistoryRoute(ctx: RequestContext) {
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const userId = await userIdFrom(ctx);
|
||||
const mode = stringParam(ctx, 'mode');
|
||||
const status = stringParam(ctx, 'status');
|
||||
const limit = intParam(ctx, 'limit', 50, 200);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select ps.id, ps.mode, ps.target_type as "targetType", ps.target_id as "targetId",
|
||||
ps.blueprint_id as "blueprintId", pb.name as "blueprintName",
|
||||
ps.collection_id as "collectionId", qc.name as "collectionName",
|
||||
ps.entry_id as "entryId", ce.name as "entryName",
|
||||
ps.content_node_id as "contentNodeId", cn.name as "contentNodeName",
|
||||
ps.question_count as "questionCount", ps.duration_minutes as "durationMinutes",
|
||||
ps.total_score as "sessionTotalScore", ps.access_mode as "accessMode",
|
||||
ps.started_at as "startedAt", ps.finished_at as "finishedAt",
|
||||
ps.expires_at as "expiresAt",
|
||||
case
|
||||
when ps.finished_at is not null then 'finished'
|
||||
when ps.expires_at is not null and ps.expires_at <= now() then 'expired'
|
||||
else 'active'
|
||||
end as status,
|
||||
coalesce(ar.answered_count, 0)::integer as "answeredCount",
|
||||
coalesce(ar.correct_count, 0)::integer as "correctCount",
|
||||
coalesce(ar.wrong_count, 0)::integer as "wrongCount",
|
||||
r.id as "reportId", r.score, r.total_score as "reportTotalScore",
|
||||
r.accuracy, r.submitted_at as "submittedAt"
|
||||
from public.practice_sessions ps
|
||||
left join public.practice_blueprints pb on pb.id = ps.blueprint_id and pb.tenant_id = ps.tenant_id
|
||||
left join public.question_collections qc on qc.id = ps.collection_id and qc.tenant_id = ps.tenant_id
|
||||
left join public.content_entries ce on ce.id = ps.entry_id and ce.tenant_id = ps.tenant_id
|
||||
left join public.content_nodes cn on cn.id = ps.content_node_id and cn.tenant_id = ps.tenant_id
|
||||
left join public.practice_session_reports r on r.practice_session_id = ps.id and r.tenant_id = ps.tenant_id and r.user_id = ps.user_id
|
||||
left join lateral (
|
||||
select count(distinct question_id)::integer as answered_count,
|
||||
count(distinct question_id) filter (where is_correct is true)::integer as correct_count,
|
||||
count(distinct question_id) filter (where is_correct is false)::integer as wrong_count
|
||||
from public.answer_records
|
||||
where tenant_id = ps.tenant_id
|
||||
and user_id = ps.user_id
|
||||
and practice_session_id = ps.id
|
||||
) ar on true
|
||||
where ps.tenant_id = $1 and ps.user_id = $2
|
||||
and ($3::text = '' or ps.mode = $3::text)
|
||||
and (
|
||||
$4::text = ''
|
||||
or ($4::text = 'finished' and ps.finished_at is not null)
|
||||
or ($4::text = 'active' and ps.finished_at is null and (ps.expires_at is null or ps.expires_at > now()))
|
||||
or ($4::text = 'expired' and ps.finished_at is null and ps.expires_at is not null and ps.expires_at <= now())
|
||||
)
|
||||
order by coalesce(ps.finished_at, ps.started_at) desc
|
||||
limit $5
|
||||
`,
|
||||
[tenantId, userId, mode, status, limit],
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function learningStatsRoute(ctx: RequestContext) {
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const userId = await userIdFrom(ctx);
|
||||
const days = intParam(ctx, 'days', 30, 365);
|
||||
|
||||
const [answerStats, sessionStats, reportStats, wrongStats, favoriteStats, typeStats] = await Promise.all([
|
||||
queryOne<{
|
||||
totalAnswered: string;
|
||||
correctCount: string;
|
||||
wrongCount: string;
|
||||
todayAnswered: string;
|
||||
latestAnsweredAt: string | null;
|
||||
}>(
|
||||
`
|
||||
select count(*)::text as "totalAnswered",
|
||||
count(*) filter (where is_correct is true)::text as "correctCount",
|
||||
count(*) filter (where is_correct is false)::text as "wrongCount",
|
||||
count(*) filter (where answered_at::date = current_date)::text as "todayAnswered",
|
||||
max(answered_at) as "latestAnsweredAt"
|
||||
from public.answer_records
|
||||
where tenant_id = $1 and user_id = $2
|
||||
`,
|
||||
[tenantId, userId],
|
||||
),
|
||||
queryOne<{
|
||||
totalSessions: string;
|
||||
activeSessions: string;
|
||||
finishedSessions: string;
|
||||
latestStartedAt: string | null;
|
||||
}>(
|
||||
`
|
||||
select count(*)::text as "totalSessions",
|
||||
count(*) filter (where finished_at is null and (expires_at is null or expires_at > now()))::text as "activeSessions",
|
||||
count(*) filter (where finished_at is not null)::text as "finishedSessions",
|
||||
max(started_at) as "latestStartedAt"
|
||||
from public.practice_sessions
|
||||
where tenant_id = $1 and user_id = $2
|
||||
`,
|
||||
[tenantId, userId],
|
||||
),
|
||||
queryOne<{
|
||||
reportCount: string;
|
||||
avgAccuracy: string | null;
|
||||
bestScore: string | null;
|
||||
latestSubmittedAt: string | null;
|
||||
}>(
|
||||
`
|
||||
select count(*)::text as "reportCount",
|
||||
avg(accuracy)::text as "avgAccuracy",
|
||||
max(score)::text as "bestScore",
|
||||
max(submitted_at) as "latestSubmittedAt"
|
||||
from public.practice_session_reports
|
||||
where tenant_id = $1 and user_id = $2
|
||||
`,
|
||||
[tenantId, userId],
|
||||
),
|
||||
queryOne<{
|
||||
unresolvedWrong: string;
|
||||
resolvedWrong: string;
|
||||
totalWrongBook: string;
|
||||
}>(
|
||||
`
|
||||
select count(*) filter (where resolved_at is null)::text as "unresolvedWrong",
|
||||
count(*) filter (where resolved_at is not null)::text as "resolvedWrong",
|
||||
count(*)::text as "totalWrongBook"
|
||||
from public.wrong_questions
|
||||
where tenant_id = $1 and user_id = $2
|
||||
`,
|
||||
[tenantId, userId],
|
||||
),
|
||||
queryOne<{ favoriteQuestions: string }>(
|
||||
`
|
||||
select count(*)::text as "favoriteQuestions"
|
||||
from public.favorite_questions
|
||||
where tenant_id = $1 and user_id = $2
|
||||
`,
|
||||
[tenantId, userId],
|
||||
),
|
||||
query<{
|
||||
questionType: string;
|
||||
typeLabel: string | null;
|
||||
answeredCount: string;
|
||||
correctCount: string;
|
||||
wrongCount: string;
|
||||
}>(
|
||||
`
|
||||
select q.type as "questionType", q.type_label as "typeLabel",
|
||||
count(*)::text as "answeredCount",
|
||||
count(*) filter (where ar.is_correct is true)::text as "correctCount",
|
||||
count(*) filter (where ar.is_correct is false)::text as "wrongCount"
|
||||
from public.answer_records ar
|
||||
join public.questions q on q.id = ar.question_id and q.tenant_id = ar.tenant_id
|
||||
where ar.tenant_id = $1
|
||||
and ar.user_id = $2
|
||||
and ar.answered_at >= current_date - ($3::integer * interval '1 day')
|
||||
group by q.type, q.type_label
|
||||
order by count(*) desc, q.type asc
|
||||
limit 20
|
||||
`,
|
||||
[tenantId, userId, days],
|
||||
),
|
||||
]);
|
||||
|
||||
const totalAnswered = Number(answerStats?.totalAnswered || 0);
|
||||
const correctCount = Number(answerStats?.correctCount || 0);
|
||||
const wrongCount = Number(answerStats?.wrongCount || 0);
|
||||
return {
|
||||
item: {
|
||||
windowDays: days,
|
||||
answers: {
|
||||
totalAnswered,
|
||||
correctCount,
|
||||
wrongCount,
|
||||
todayAnswered: Number(answerStats?.todayAnswered || 0),
|
||||
accuracy: round4(totalAnswered ? correctCount / totalAnswered : 0),
|
||||
latestAnsweredAt: answerStats?.latestAnsweredAt || null,
|
||||
},
|
||||
sessions: {
|
||||
totalSessions: Number(sessionStats?.totalSessions || 0),
|
||||
activeSessions: Number(sessionStats?.activeSessions || 0),
|
||||
finishedSessions: Number(sessionStats?.finishedSessions || 0),
|
||||
latestStartedAt: sessionStats?.latestStartedAt || null,
|
||||
},
|
||||
reports: {
|
||||
reportCount: Number(reportStats?.reportCount || 0),
|
||||
avgAccuracy: round4(finiteNumber(reportStats?.avgAccuracy, 0)),
|
||||
bestScore: round2(finiteNumber(reportStats?.bestScore, 0)),
|
||||
latestSubmittedAt: reportStats?.latestSubmittedAt || null,
|
||||
},
|
||||
wrongBook: {
|
||||
unresolvedWrong: Number(wrongStats?.unresolvedWrong || 0),
|
||||
resolvedWrong: Number(wrongStats?.resolvedWrong || 0),
|
||||
totalWrongBook: Number(wrongStats?.totalWrongBook || 0),
|
||||
},
|
||||
favorites: {
|
||||
favoriteQuestions: Number(favoriteStats?.favoriteQuestions || 0),
|
||||
},
|
||||
questionTypes: typeStats.map(item => {
|
||||
const answered = Number(item.answeredCount || 0);
|
||||
const correct = Number(item.correctCount || 0);
|
||||
return {
|
||||
questionType: item.questionType,
|
||||
typeLabel: item.typeLabel,
|
||||
answeredCount: answered,
|
||||
correctCount: correct,
|
||||
wrongCount: Number(item.wrongCount || 0),
|
||||
accuracy: round4(answered ? correct / answered : 0),
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function learningTrendRoute(ctx: RequestContext) {
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const userId = await userIdFrom(ctx);
|
||||
const days = intParam(ctx, 'days', 14, 365);
|
||||
const from = dateParam(ctx, 'from');
|
||||
const to = dateParam(ctx, 'to');
|
||||
|
||||
const rows = await query<TrendMetric>(
|
||||
`
|
||||
with bounds as (
|
||||
select
|
||||
coalesce($3::date, current_date - (($5::integer - 1) * interval '1 day'))::date as start_date,
|
||||
coalesce($4::date, current_date)::date as end_date
|
||||
),
|
||||
dates as (
|
||||
select generate_series(start_date, end_date, interval '1 day')::date as stat_date
|
||||
from bounds
|
||||
),
|
||||
answers as (
|
||||
select answered_at::date as stat_date,
|
||||
count(*)::integer as answered_count,
|
||||
count(*) filter (where is_correct is true)::integer as correct_count,
|
||||
count(*) filter (where is_correct is false)::integer as wrong_count
|
||||
from public.answer_records, bounds
|
||||
where tenant_id = $1 and user_id = $2
|
||||
and answered_at::date between bounds.start_date and bounds.end_date
|
||||
group by answered_at::date
|
||||
),
|
||||
sessions as (
|
||||
select started_at::date as stat_date, count(*)::integer as session_count
|
||||
from public.practice_sessions, bounds
|
||||
where tenant_id = $1 and user_id = $2
|
||||
and started_at::date between bounds.start_date and bounds.end_date
|
||||
group by started_at::date
|
||||
),
|
||||
reports as (
|
||||
select submitted_at::date as stat_date,
|
||||
count(*)::integer as report_count,
|
||||
coalesce(sum(score), 0)::numeric as score,
|
||||
coalesce(sum(total_score), 0)::numeric as total_score
|
||||
from public.practice_session_reports, bounds
|
||||
where tenant_id = $1 and user_id = $2
|
||||
and submitted_at::date between bounds.start_date and bounds.end_date
|
||||
group by submitted_at::date
|
||||
)
|
||||
select dates.stat_date::text as date,
|
||||
coalesce(answers.answered_count, 0)::integer as "answeredCount",
|
||||
coalesce(answers.correct_count, 0)::integer as "correctCount",
|
||||
coalesce(answers.wrong_count, 0)::integer as "wrongCount",
|
||||
case when coalesce(answers.answered_count, 0) = 0 then 0
|
||||
else round((answers.correct_count::numeric / answers.answered_count::numeric), 4)
|
||||
end as accuracy,
|
||||
coalesce(sessions.session_count, 0)::integer as "sessionCount",
|
||||
coalesce(reports.report_count, 0)::integer as "reportCount",
|
||||
coalesce(reports.score, 0)::numeric as score,
|
||||
coalesce(reports.total_score, 0)::numeric as "totalScore"
|
||||
from dates
|
||||
left join answers on answers.stat_date = dates.stat_date
|
||||
left join sessions on sessions.stat_date = dates.stat_date
|
||||
left join reports on reports.stat_date = dates.stat_date
|
||||
order by dates.stat_date asc
|
||||
`,
|
||||
[tenantId, userId, from || null, to || null, days],
|
||||
);
|
||||
|
||||
return {
|
||||
items: rows.map(row => ({
|
||||
...row,
|
||||
answeredCount: Number(row.answeredCount || 0),
|
||||
correctCount: Number(row.correctCount || 0),
|
||||
wrongCount: Number(row.wrongCount || 0),
|
||||
accuracy: finiteNumber(row.accuracy, 0),
|
||||
sessionCount: Number(row.sessionCount || 0),
|
||||
reportCount: Number(row.reportCount || 0),
|
||||
score: finiteNumber(row.score, 0),
|
||||
totalScore: finiteNumber(row.totalScore, 0),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function wrongQuestionReviewPlanRoute(ctx: RequestContext) {
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const userId = await userIdFrom(ctx);
|
||||
const limit = intParam(ctx, 'limit', 20, 100);
|
||||
const includeResolved = stringParam(ctx, 'includeResolved') === 'true';
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select wq.question_id as "questionId", wq.wrong_count as "wrongCount",
|
||||
wq.last_wrong_at as "lastWrongAt", wq.resolved_at as "resolvedAt",
|
||||
q.type, q.type_label as "typeLabel", q.subject_id as "subjectId",
|
||||
s.name as "subjectName", q.category_id as "categoryId",
|
||||
c.name as "categoryName", q.content_node_id as "contentNodeId",
|
||||
cn.name as "contentNodeName", v.content,
|
||||
coalesce(ar.answer_attempts, 0)::integer as "answerAttempts",
|
||||
ar.last_answered_at as "lastAnsweredAt",
|
||||
case
|
||||
when wq.resolved_at is not null then 'resolved'
|
||||
when wq.wrong_count >= 3 then 'high_priority'
|
||||
when wq.last_wrong_at <= now() - interval '3 days' then 'due'
|
||||
else 'new'
|
||||
end as "reviewStatus",
|
||||
(
|
||||
wq.last_wrong_at
|
||||
+ case
|
||||
when wq.wrong_count >= 3 then interval '1 day'
|
||||
when wq.wrong_count = 2 then interval '2 days'
|
||||
else interval '3 days'
|
||||
end
|
||||
) as "suggestedReviewAt"
|
||||
from public.wrong_questions wq
|
||||
join public.questions q on q.id = wq.question_id and q.tenant_id = wq.tenant_id
|
||||
left join public.question_versions v on v.id = q.current_version_id
|
||||
left join public.subjects s on s.id = q.subject_id and s.tenant_id = q.tenant_id
|
||||
left join public.categories c on c.id = q.category_id and c.tenant_id = q.tenant_id
|
||||
left join public.content_nodes cn on cn.id = q.content_node_id and cn.tenant_id = q.tenant_id
|
||||
left join lateral (
|
||||
select count(*)::integer as answer_attempts, max(answered_at) as last_answered_at
|
||||
from public.answer_records
|
||||
where tenant_id = wq.tenant_id
|
||||
and user_id = wq.user_id
|
||||
and question_id = wq.question_id
|
||||
) ar on true
|
||||
where wq.tenant_id = $1
|
||||
and wq.user_id = $2
|
||||
and ($3::boolean = true or wq.resolved_at is null)
|
||||
and q.status = 'published'
|
||||
order by
|
||||
case
|
||||
when wq.resolved_at is not null then 4
|
||||
when wq.wrong_count >= 3 then 0
|
||||
when wq.last_wrong_at <= now() - interval '3 days' then 1
|
||||
else 2
|
||||
end,
|
||||
wq.wrong_count desc,
|
||||
wq.last_wrong_at asc
|
||||
limit $4
|
||||
`,
|
||||
[tenantId, userId, includeResolved, limit],
|
||||
);
|
||||
|
||||
return {
|
||||
items,
|
||||
nextAction: {
|
||||
mode: 'wrong_review',
|
||||
endpoint: 'POST /api/learning/practice-sessions',
|
||||
body: { mode: 'wrong_review', questionLimit: Math.min(limit, 50) },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function favoriteQuestionsRoute(ctx: RequestContext) {
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const userId = await userIdFrom(ctx);
|
||||
|
||||
Reference in New Issue
Block a user