Direct SQL is intentional here: the page boundary applies to grouped skill-version attempts, + * not individual review tasks. Window functions keep grouping, latest-attempt selection, counts, + * filtering, and pagination in the database instead of loading an author's full history.
+ */ +@Repository +public class JpaReviewProgressQueryRepository implements ReviewProgressQueryRepository { + + private static final String RANKED_CTE = """ + WITH ranked AS ( + SELECT task.id, + task.skill_id, + task.namespace_id, + task.skill_version, + task.status, + task.review_comment, + task.submitted_at, + task.reviewed_at, + ROW_NUMBER() OVER ( + PARTITION BY task.skill_id, task.skill_version + ORDER BY task.submitted_at DESC, task.id DESC + ) AS attempt_rank, + COUNT(*) OVER ( + PARTITION BY task.skill_id, task.skill_version + ) AS attempt_count + FROM review_task task + WHERE task.submitted_by = :userId + ), latest AS ( + SELECT * + FROM ranked + WHERE attempt_rank = 1 + ) + """; + + private static final String MY_PROGRESS_SQL = RANKED_CTE + """ + SELECT latest.id, + latest.skill_id, + namespace.slug, + skill.slug, + latest.skill_version, + latest.status, + latest.review_comment, + latest.submitted_at, + latest.reviewed_at, + latest.attempt_count + FROM latest + JOIN skill ON skill.id = latest.skill_id + JOIN namespace ON namespace.id = latest.namespace_id + WHERE ( + :query = '' + OR LOWER(skill.slug) LIKE :queryPattern + OR LOWER(namespace.slug) LIKE :queryPattern + ) + AND (:status = '' OR latest.status = :status) + ORDER BY latest.submitted_at DESC, latest.id DESC + OFFSET :offset ROWS FETCH NEXT :size ROWS ONLY + """; + + private static final String MY_PROGRESS_SUMMARY_SQL = RANKED_CTE + """ + SELECT COUNT(*) FILTER (WHERE :status = '' OR latest.status = :status) AS filtered_total, + COUNT(*) FILTER (WHERE latest.status = 'PENDING') AS pending_count, + COUNT(*) FILTER (WHERE latest.status = 'APPROVED') AS approved_count, + COUNT(*) FILTER (WHERE latest.status = 'REJECTED') AS rejected_count + FROM latest + JOIN skill ON skill.id = latest.skill_id + JOIN namespace ON namespace.id = latest.namespace_id + WHERE :query = '' + OR LOWER(skill.slug) LIKE :queryPattern + OR LOWER(namespace.slug) LIKE :queryPattern + """; + + private final EntityManager entityManager; + + public JpaReviewProgressQueryRepository(EntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + @Transactional(readOnly = true) + public ReviewProgressPageResponse findMyProgress( + String userId, + ReviewTaskStatus status, + String query, + int page, + int size) { + String normalizedQuery = query == null ? "" : query.trim().toLowerCase(java.util.Locale.ROOT); + String statusName = status != null ? status.name() : ""; + String queryPattern = "%" + normalizedQuery + "%"; + Query nativeQuery = bindFilters( + entityManager.createNativeQuery(MY_PROGRESS_SQL), + userId, + statusName, + normalizedQuery, + queryPattern) + .setParameter("offset", (long) page * size) + .setParameter("size", size); + Query summaryQuery = bindFilters( + entityManager.createNativeQuery(MY_PROGRESS_SUMMARY_SQL), + userId, + statusName, + normalizedQuery, + queryPattern); + + @SuppressWarnings("unchecked") + List