mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
172 lines
7.3 KiB
JavaScript
172 lines
7.3 KiB
JavaScript
/***********************************************************************************************************************************************************************
|
|
* 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,
|
|
}; |