From 7a672d2f5cfc990102992f4064863cd2ca18a86d Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Wed, 19 Aug 2026 14:14:50 +0200 Subject: [PATCH] feat(vocab): add logic to aggregate and clean up duplicate SRS items based on answer patterns --- ...000-add-bisaya-rest-answer-alternative.cjs | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/backend/migrations-active/20260818000000-add-bisaya-rest-answer-alternative.cjs b/backend/migrations-active/20260818000000-add-bisaya-rest-answer-alternative.cjs index 7470627..9b30cbe 100644 --- a/backend/migrations-active/20260818000000-add-bisaya-rest-answer-alternative.cjs +++ b/backend/migrations-active/20260818000000-add-bisaya-rest-answer-alternative.cjs @@ -95,6 +95,90 @@ module.exports = { transaction }); + await queryInterface.sequelize.query(` + WITH candidates AS ( + SELECT id, user_id, + ENCODE(DIGEST(CONCAT_WS( + '|', + course_id::text, + COALESCE(lesson_id::text, 'course'), + UPPER(direction), + :normalizedCombinedAnswer, + :normalizedTarget + ), 'sha1'), 'hex') AS new_item_key, + correct_count, wrong_count, lapse_count, stage, interval_days, + last_reviewed_at, next_due_at + FROM community.vocab_srs_item + WHERE learning = :shortAnswer + AND reference = :target + ), ranked AS ( + SELECT *, ROW_NUMBER() OVER (PARTITION BY user_id, new_item_key ORDER BY id) AS position + FROM candidates + ), aggregates AS ( + SELECT user_id, new_item_key, MIN(id) AS keeper_id, + SUM(correct_count) AS correct_count, + SUM(wrong_count) AS wrong_count, + SUM(lapse_count) AS lapse_count, + MAX(stage) AS stage, + MAX(interval_days) AS interval_days, + MAX(last_reviewed_at) AS last_reviewed_at, + MIN(next_due_at) AS next_due_at + FROM ranked + GROUP BY user_id, new_item_key + ) + UPDATE community.vocab_srs_item AS keeper + SET correct_count = aggregates.correct_count, + wrong_count = aggregates.wrong_count, + lapse_count = aggregates.lapse_count, + stage = aggregates.stage, + interval_days = aggregates.interval_days, + last_reviewed_at = aggregates.last_reviewed_at, + next_due_at = aggregates.next_due_at, + updated_at = NOW() + FROM aggregates + WHERE keeper.id = aggregates.keeper_id; + `, { + replacements: { + target: TARGET, + shortAnswer: SHORT_ANSWER, + normalizedCombinedAnswer: NORMALIZED_COMBINED_ANSWER, + normalizedTarget: NORMALIZED_TARGET + }, + transaction + }); + + await queryInterface.sequelize.query(` + WITH candidates AS ( + SELECT id, user_id, + ENCODE(DIGEST(CONCAT_WS( + '|', + course_id::text, + COALESCE(lesson_id::text, 'course'), + UPPER(direction), + :normalizedCombinedAnswer, + :normalizedTarget + ), 'sha1'), 'hex') AS new_item_key + FROM community.vocab_srs_item + WHERE learning = :shortAnswer + AND reference = :target + ), ranked AS ( + SELECT id, ROW_NUMBER() OVER (PARTITION BY user_id, new_item_key ORDER BY id) AS position + FROM candidates + ) + DELETE FROM community.vocab_srs_item AS duplicate + USING ranked + WHERE duplicate.id = ranked.id + AND ranked.position > 1; + `, { + replacements: { + target: TARGET, + shortAnswer: SHORT_ANSWER, + normalizedCombinedAnswer: NORMALIZED_COMBINED_ANSWER, + normalizedTarget: NORMALIZED_TARGET + }, + transaction + }); + await queryInterface.sequelize.query(` UPDATE community.vocab_srs_item SET learning = :combinedAnswer,