/*********************************************************************************************************************************************************************** * File Name: achievements.service.js * Type of Program: Service * Description: Registry-based achievement auto-grant system. * To add a new achievement — add an entry to utils/achievements.data.js. * To trigger one — call grantAchievement(user_id, key, metadata). * * Author: Kenneth Obsequio (@lash0000) * Date Created: Jun. 11, 2026 ***********************************************************************************************************************************************************************/ 'use strict'; const { Op } = require('sequelize'); const mdl_Achievements = require('../models/users/achievements.mdl'); const mdl_AchievementDefinitions = require('../models/users/achievement_definitions.mdl'); const mdl_Users = require('../models/users/users.mdl'); const { EARLY_ACCESS_CUTOFF } = require('../data/achievements.data'); const UserNotification = require('../models/notifications/user_notification.mdl'); const { NOTIFICATION_REGISTRY } = require('../data/notifications.data'); // ─── Core grant function ────────────────────────────────────────────────────── /** * Grant an achievement to a user if not already granted. * Safe to call multiple times — idempotent via findOrCreate. * * @param {string|number} user_id * @param {string} key — must exist in the achievement_definitions catalog * @param {Object} metadata — optional extra data (course_id, score, etc.) * @param {string|number} granted_by — null = system, user_id = admin manual grant * @returns {{ achievement, created }} or null on error */ async function grantAchievement(user_id, key, metadata = {}, granted_by = null) { const def = await mdl_AchievementDefinitions.findOne({ where: { key, is_active: true } }); if (!def) { console.warn(`[ACHIEVEMENTS] Unknown or inactive achievement key: "${key}"`); return null; } try { // CockroachDB doesn't support findOrCreate — use findOne + create const existing = await mdl_Achievements.findOne({ where: { user_id, key } }); if (existing) return { achievement: existing, created: false }; const achievement = await mdl_Achievements.create({ user_id, type: def.type, key: def.key, label: def.label, description: def.description, icon: def.icon, granted_by: granted_by ?? null, granted_at: new Date(), metadata, }); console.log(`[ACHIEVEMENTS] Granted "${key}" to user ${user_id}`); // Fire-and-forget — notification failure never blocks the achievement grant UserNotification.create({ user_id, ...NOTIFICATION_REGISTRY.achievement.build({ label: def.label, description: def.description, key: def.key, }), }).catch(err => console.error(`[ACHIEVEMENTS] Failed to emit notification for "${key}":`, err)); return { achievement, created: true }; } catch (err) { // Ignore unique constraint violation (race condition) — already granted if (err?.parent?.code === '23505') return null; console.error(`[ACHIEVEMENTS] Failed to grant "${key}" to user ${user_id}:`, err); return null; } } // ─── Convenience triggers ───────────────────────────────────────────────────── // Call these from controllers after the relevant event occurs. /** Call after verifyOTP / register succeeds */ async function onUserRegistered(user_id) { const user = await mdl_Users.findByPk(user_id, { attributes: ['user_id', 'createdAt'] }); if (!user) return; if (new Date(user.createdAt) <= EARLY_ACCESS_CUTOFF) { await grantAchievement(user_id, 'early_access'); } } /** Call after captureOrder succeeds and tier is activated */ async function onTierActivated(user_id, tier) { if (tier === 'premium') await grantAchievement(user_id, 'premium_first_time'); if (tier === 'exclusive') await grantAchievement(user_id, 'exclusive_first_time'); } /** Call after a course is marked complete for a user */ async function onCourseCompleted(user_id, course_id, totalCompleted, course_title = null) { await grantAchievement(user_id, 'first_course_completed', { course_id, course_title }); if (totalCompleted >= 5) await grantAchievement(user_id, 'courses_completed_5'); if (totalCompleted >= 10) await grantAchievement(user_id, 'courses_completed_10'); } /** Call after a perfect quiz score */ async function onPerfectQuiz(user_id, quiz_id) { await grantAchievement(user_id, 'perfect_quiz_score', { quiz_id }); } /** Call after a single task's requirements are all satisfied for a user */ async function onTaskCompleted(user_id, task_id, task_name = null) { await grantAchievement(user_id, 'first_task_completed', { task_id, task_name }); } /** Call after every task in a task list is complete for a user */ async function onTaskListCompleted(user_id, task_list_id, task_list_name = null) { await grantAchievement(user_id, 'task_list_finisher', { task_list_id, task_list_name }); } /** Call after profile is fully filled out */ async function onProfileCompleted(user_id) { await grantAchievement(user_id, 'profile_completed'); } /** Call after a successful referral */ async function onReferral(user_id, referred_user_id) { await grantAchievement(user_id, 'first_referral', { referred_user_id }); } // ─── Admin manual grant ─────────────────────────────────────────────────────── /** Manually grant any achievement from admin panel */ async function adminGrantAchievement(user_id, key, admin_id, metadata = {}) { return grantAchievement(user_id, key, metadata, admin_id); } // ─── Backfill ───────────────────────────────────────────────────────────────── /** Run once to grant early_access to all existing eligible users */ async function backfillEarlyAccess() { const users = await mdl_Users.findAll({ where: { createdAt: { [Op.lte]: EARLY_ACCESS_CUTOFF } }, attributes: ['user_id'], paranoid: false, }); console.log(`[ACHIEVEMENTS] Backfilling early_access for ${users.length} users...`); for (const user of users) { await grantAchievement(user.user_id, 'early_access'); } console.log('[ACHIEVEMENTS] Backfill complete.'); } // ─── Exports ────────────────────────────────────────────────────────────────── module.exports = { grantAchievement, // Convenience triggers onUserRegistered, onTierActivated, onCourseCompleted, onPerfectQuiz, onProfileCompleted, onReferral, onTaskCompleted, onTaskListCompleted, // Admin adminGrantAchievement, // Backfill backfillEarlyAccess, };